[freeside-commits] branch master updated. 1ebcca94aba75c5901c6eefaf373f39e94b03cf0

Mark Wells mark at 420.am
Wed Aug 6 14:11:31 PDT 2014


The branch, master has been updated
       via  1ebcca94aba75c5901c6eefaf373f39e94b03cf0 (commit)
       via  d7cf0d6bb3b81b1c91ef1bcc3252d56f96b65b0f (commit)
       via  0f359d5480aa1621d73ee802f420e8951abc620d (commit)
      from  6c284750de8fe49d7d4cdc6a9a4fb618697780e2 (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 1ebcca94aba75c5901c6eefaf373f39e94b03cf0
Author: Mark Wells <mark at freeside.biz>
Date:   Wed Aug 6 14:11:01 2014 -0700

    477 report: mobile deployment info

diff --git a/FS/FS/Report/FCC_477.pm b/FS/FS/Report/FCC_477.pm
index 86fa0a6..0f3dfb1 100644
--- a/FS/FS/Report/FCC_477.pm
+++ b/FS/FS/Report/FCC_477.pm
@@ -11,7 +11,7 @@ use FS::Record qw( dbh );
 use Tie::IxHash;
 use Storable;
 
-$DEBUG = 1;
+$DEBUG = 0;
 
 =head1 NAME
 
@@ -259,9 +259,11 @@ sub active_on {
 }
 
 sub is_fixed_broadband {
-  "is_broadband::int = 1 AND technology::int IN(".join(',',
-    10, 11, 12, 20, 30, 40, 41, 42, 50, 60, 70, 90, 0
-  ).")";
+  "is_broadband::int = 1 AND technology::int IN( 10, 11, 12, 20, 30, 40, 41, 42, 50, 60, 70, 90, 0 )"
+}
+
+sub is_mobile_broadband {
+  "is_broadband::int = 1 AND technology::int IN( 80, 81, 82, 83, 84, 85, 86, 87, 88)"
 }
 
 =item report SECTION, OPTIONS
@@ -290,7 +292,7 @@ sub report {
 
 sub fbd_sql {
   my $class = shift;
-  my %opt = shift;
+  my %opt = @_;
   my $date = $opt{date} || time;
   warn $date;
   my $agentnum = $opt{agentnum};
@@ -328,7 +330,7 @@ sub fbd_sql {
 
 sub fbs_sql {
   my $class = shift;
-  my %opt = shift;
+  my %opt = @_;
   my $date = $opt{date} || time;
   my $agentnum = $opt{agentnum};
 
@@ -371,7 +373,7 @@ sub fbs_sql {
 
 sub fvs_sql {
   my $class = shift;
-  my %opt = shift;
+  my %opt = @_;
   my $date = $opt{date} || time;
   my $agentnum = $opt{agentnum};
 
@@ -413,7 +415,7 @@ sub fvs_sql {
 
 sub lts_sql {
   my $class = shift;
-  my %opt = shift;
+  my %opt = @_;
   my $date = $opt{date} || time;
   my $agentnum = $opt{agentnum};
 
@@ -465,7 +467,7 @@ sub lts_sql {
 
 sub voip_sql {
   my $class = shift;
-  my %opt = shift;
+  my %opt = @_;
   my $date = $opt{date} || time;
   my $agentnum = $opt{agentnum};
 
@@ -510,7 +512,82 @@ sub voip_sql {
   GROUP BY $group_by
   ORDER BY $order_by
   ";
+}
+
+sub mbs_sql {
+  my $class = shift;
+  my %opt = @_;
+  my $date = $opt{date} || time;
+  my $agentnum = $opt{agentnum};
+
+  my @select = (
+    'state.fips',
+    'broadband_downstream',
+    'broadband_upstream',
+    'COUNT(*)',
+    'COUNT(is_consumer)',
+  );
+  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_broadband technology
+        is_consumer
+        )).
+      join_optionnames(qw(broadband_downstream broadband_upstream))
+  ;
+  my @where = (
+    active_on($date),
+    is_mobile_broadband()
+  );
+  push @where, "cust_main.agentnum = $agentnum" if $agentnum;
+  my $group_by = 'state.fips, broadband_downstream, broadband_upstream ';
+  my $order_by = $group_by;
+
+  "SELECT ".join(', ', @select) . "
+  FROM $from
+  WHERE ".join(' AND ', @where)."
+  GROUP BY $group_by
+  ORDER BY $order_by
+  ";
+}
+
+sub mvs_sql {
+  my $class = shift;
+  my %opt = @_;
+  my $date = $opt{date} || time;
+  my $agentnum = $opt{agentnum};
+
+  my @select = (
+    'state.fips',
+    'COUNT(*)',
+    'COUNT(mobile_direct)',
+  );
+  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_mobile mobile_direct) )
+  ;
+  my @where = (
+    active_on($date),
+    'is_mobile = 1'
+  );
+  push @where, "cust_main.agentnum = $agentnum" if $agentnum;
+  my $group_by = 'state.fips';
+  my $order_by = $group_by;
 
+  "SELECT ".join(', ', @select) . "
+  FROM $from
+  WHERE ".join(' AND ', @where)."
+  GROUP BY $group_by
+  ORDER BY $order_by
+  ";
 }
 
 =item parts
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 2b6dc6d..1b82e0e 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -6682,11 +6682,12 @@ sub tables_hashref {
         'zonetype',       'char',    '',     1,       '', '',
         'technology',     'int',     '',     '',      '', '',
         'spectrum',       'int',     'NULL', '',      '', '',
-        'servicetype',    'char',    '',     '12',    '', '',
         'adv_speed_up',   'decimal', '',     '10,3', '0', '',
         'adv_speed_down', 'decimal', '',     '10,3', '0', '',
         'cir_speed_up',   'decimal', '',     '10,3', '0', '',
         'cir_speed_down', 'decimal', '',     '10,3', '0', '',
+        'is_broadband',   'char',    'NULL', 1,       '', '',
+        'is_voice',       'char',    'NULL', 1,       '', '',
         'is_consumer',    'char',    'NULL', 1,       '', '',
         'is_business',    'char',    'NULL', 1,       '', '',
         'active_date',    @date_type,                 '', '',
@@ -6727,10 +6728,9 @@ sub tables_hashref {
         'zonenum',        'int',     '',     '',      '', '',
         'latitude',       'decimal', '',     '10,7',  '', '', 
         'longitude',      'decimal', '',     '10,7',  '', '', 
-        'sequence',       'int',     '',     '',      '', '',
       ],
       'primary_key' => 'vertexnum',
-      'unique' => [ [ 'zonenum', 'sequence' ] ],
+      'unique' => [ ],
       'index'  => [ ],
       'foreign_keys' => [
                           { columns     => [ 'zonenum' ],
diff --git a/FS/FS/deploy_zone.pm b/FS/FS/deploy_zone.pm
index 227a022..16f59c8 100644
--- a/FS/FS/deploy_zone.pm
+++ b/FS/FS/deploy_zone.pm
@@ -65,10 +65,6 @@ The FCC technology code for the type of service available.
 
 For mobile service zones, the FCC code for the RF band.
 
-=item servicetype
-
-"broadband" or "voice"
-
 =item adv_speed_up
 
 For broadband, the advertised upstream bandwidth in the zone.  If multiple
@@ -97,6 +93,14 @@ type of service is sold.
 'Y' if this service is sold to business or institutional use.  Not mutually
 exclusive with is_consumer.
 
+=item is_broadband
+
+'Y' if this service includes broadband Internet.
+
+=item is_voice
+
+'Y' if this service includes voice communication.
+
 =item active_date
 
 The date this zone became active.
@@ -180,25 +184,30 @@ sub check {
     || $self->ut_textn('description')
     || $self->ut_number('agentnum')
     || $self->ut_foreign_key('agentnum', 'agent', 'agentnum')
-    || $self->ut_alphan('dbaname')
+    || $self->ut_textn('dbaname')
     || $self->ut_enum('zonetype', [ 'B', 'P' ])
     || $self->ut_number('technology')
     || $self->ut_numbern('spectrum')
-    || $self->ut_enum('servicetype', [ 'broadband', 'voice' ])
     || $self->ut_decimaln('adv_speed_up', 3)
     || $self->ut_decimaln('adv_speed_down', 3)
     || $self->ut_decimaln('cir_speed_up', 3)
     || $self->ut_decimaln('cir_speed_down', 3)
     || $self->ut_flag('is_consumer')
     || $self->ut_flag('is_business')
+    || $self->ut_flag('is_broadband')
+    || $self->ut_flag('is_voice')
     || $self->ut_numbern('active_date')
     || $self->ut_numbern('expire_date')
   ;
   return $error if $error;
 
   foreach(qw(adv_speed_down adv_speed_up cir_speed_down cir_speed_up)) {
-    if (!$self->get($_)) {
-      $self->set($_, 0);
+    if ($self->get('is_broadband')) {
+      if (!$self->get($_)) {
+        $self->set($_, 0);
+      }
+    } else {
+      $self->set($_, '');
     }
   }
   if (!$self->get('active_date')) {
@@ -226,7 +235,35 @@ sub element_table {
   }
 }
 
-=back
+=item deploy_zone_block
+
+Returns the census block records in this zone, in order by census block
+number.  Only appropriate to block-type zones.
+
+=item deploy_zone_vertex
+
+Returns the vertex records for this zone, in order by sequence number.  Only
+appropriate to polygon-type zones.
+
+=cut
+
+sub deploy_zone_block {
+  my $self = shift;
+  qsearch({
+      table     => 'deploy_zone_block',
+      hashref   => { zonenum => $self->zonenum },
+      order_by  => ' ORDER BY censusblock',
+  });
+}
+
+sub deploy_zone_vertex {
+  my $self = shift;
+  qsearch({
+      table     => 'deploy_zone_vertex',
+      hashref   => { zonenum => $self->zonenum },
+      order_by  => ' ORDER BY vertexnum',
+  });
+}
 
 =head1 BUGS
 
diff --git a/FS/FS/deploy_zone_vertex.pm b/FS/FS/deploy_zone_vertex.pm
index a25bfde..078b326 100644
--- a/FS/FS/deploy_zone_vertex.pm
+++ b/FS/FS/deploy_zone_vertex.pm
@@ -47,10 +47,6 @@ Latitude, as a decimal; positive values are north of the Equator.
 
 Longitude, as a decimal; positive values are east of Greenwich.
 
-=item sequence
-
-The ordinal position of this vertex, starting with zero.
-
 =back
 
 =head1 METHODS
@@ -104,7 +100,6 @@ sub check {
     || $self->ut_number('zonenum')
     || $self->ut_coord('latitude')
     || $self->ut_coord('longitude')
-    || $self->ut_number('sequence')
   ;
   return $error if $error;
 
diff --git a/FS/FS/part_pkg_fcc_option.pm b/FS/FS/part_pkg_fcc_option.pm
index a090b96..5c78e5f 100644
--- a/FS/FS/part_pkg_fcc_option.pm
+++ b/FS/FS/part_pkg_fcc_option.pm
@@ -139,6 +139,23 @@ tie our %technology_labels, 'Tie::IxHash',  (
   0  => 'Other'
 );
 
+tie our %spectrum_labels, 'Tie::IxHash', (
+  90 => '700 MHz Band',
+  91 => 'Cellular Band',
+  92 => 'Specialized Mobile Radio (SMR) Band',
+  93 => 'Advanced Wireless Services (AWS) 1 Band',
+  94 => 'Broadband Personal Communications Service (PCS) Band',
+  95 => 'Wireless Communications Service (WCS) Band',
+  96 => 'Broadband Radio Service/Educational Broadband Service Band',
+  97 => 'Satellite (e.g. L-band, Big LEO, Little LEO)',
+  98 => 'Unlicensed (including broadcast television “white spaces”) Bands',
+  99 => '600 MHz',
+  100 => 'H Block',
+  101 => 'Advanced Wireless Services (AWS) 3 Band',
+  102 => 'Advanced Wireless Services (AWS) 4 Band',
+  103 => 'Other',
+);
+
 sub media_types {
   Storable::dclone(\%media_types);
 }
@@ -147,6 +164,10 @@ sub technology_labels {
   Storable::dclone(\%technology_labels);
 }
 
+sub spectrum_labels {
+  Storable::dclone(\%spectrum_labels);
+}
+
 =head1 BUGS
 
 =head1 SEE ALSO
diff --git a/httemplate/browse/deploy_zone.html b/httemplate/browse/deploy_zone.html
index 489a226..ddfbde4 100644
--- a/httemplate/browse/deploy_zone.html
+++ b/httemplate/browse/deploy_zone.html
@@ -48,9 +48,9 @@
   sort_fields     => [ 'zonenum',
                        'description',
                        'technology',
-                       'is_consumer is not null, is_business is not null',
-                       'adv_speed_down, adv_speed_up',
-                       'cir_speed_down, cir_speed_up',
+                       '(is_consumer is not null, is_business is not null)',
+                       '(adv_speed_down, adv_speed_up)',
+                       '(cir_speed_down, cir_speed_up)',
                      ],
   links           => [  '', $link_fixed, ],
   align           => 'clllllr',
@@ -58,6 +58,59 @@
   disable_maxselect => 1,
   disable_total     => 1,
 &>
+<P><FONT SIZE="+1"><B>Mobile Zones</B></FONT></P>
+<& elements/browse.html,
+  name_singular   => 'zone',
+  query           => { table      => 'deploy_zone',
+                       hashref    => { zonetype => 'P' },
+                     },
+  count_query     => "SELECT COUNT(*) FROM deploy_zone WHERE zonetype = 'P'",
+  agent_virt      => 1,
+  header          => [  '#',
+                        'Description',
+                        'Technology',
+                        'Spectrum',
+                        'Service Type',
+                        'Advertised Mbps',
+                        'Vertices', # number of vertices? not so useful
+                     ],
+  fields          => [  'zonenum',
+                        'description',
+                        sub { my $self = shift;
+                              $tech_label->{$self->technology} },
+                        sub { my $self = shift;
+                              $spec_label->{$self->spectrum} },
+                        sub { my $self = shift;
+                              join( ' / ',
+                                $self->is_voice ? 'voice' : (),
+                                $self->is_broadband ? 'broadband' : (),
+                              )
+                            },
+                        sub { my $self = shift;
+                              join( ' / ', grep $_,
+                                $self->adv_speed_down,
+                                $self->adv_speed_up
+                              )
+                            },
+                        sub { my $self = shift;
+                              FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
+                            },
+                     ],
+  sort_fields     => [ 'zonenum',
+                       'description',
+                       'technology',
+                       'spectrum',
+                       '(is_voice is not null, is_broadband is not null)',
+                       '(adv_speed_down, adv_speed_up)',
+                     ],
+  links           => [  '', $link_mobile, ],
+  align           => 'clllllr',
+  nohtmlheader    => 1,
+  disable_maxselect => 1,
+  disable_total     => 1,
+&>
+
+<& /elements/footer.html &>
 <%init>
 my $curuser = $FS::CurrentUser::CurrentUser;
 my $acl_edit = $curuser->access_right('Edit FCC report configuration');
@@ -66,7 +119,8 @@ die "access denied"
   unless $acl_edit or $acl_edit_global;
 
 my $link_fixed = [ $p.'edit/deploy_zone-fixed.html?', 'zonenum' ];
-my $link_mobile= [ $p.'edit/deploy_zone-mobile.html', 'zonenum' ];
+my $link_mobile= [ $p.'edit/deploy_zone-mobile.html?', 'zonenum' ];
 
 my $tech_label = FS::part_pkg_fcc_option->technology_labels;
+my $spec_label = FS::part_pkg_fcc_option->spectrum_labels;
 </%init>
diff --git a/httemplate/browse/part_pkg-fcc.html b/httemplate/browse/part_pkg-fcc.html
index 9facd10..14dfcba 100755
--- a/httemplate/browse/part_pkg-fcc.html
+++ b/httemplate/browse/part_pkg-fcc.html
@@ -30,8 +30,8 @@
 
 my $curuser = $FS::CurrentUser::CurrentUser;
 
-my $edit        = 'Edit package definitions';
-my $edit_global = 'Edit global package definitions';
+my $edit        = 'Edit FCC report configuration';
+my $edit_global = 'Edit FCC report configuration for all agents';
 my $acl_edit        = $curuser->access_right($edit);
 my $acl_edit_global = $curuser->access_right($edit_global);
 
diff --git a/httemplate/edit/deploy_zone-fixed.html b/httemplate/edit/deploy_zone-fixed.html
index 8c6d54e..1a79500 100644
--- a/httemplate/edit/deploy_zone-fixed.html
+++ b/httemplate/edit/deploy_zone-fixed.html
@@ -21,9 +21,9 @@
           type          => 'hidden',
           value         => 'B'
         },
-        { field         => 'servicetype',
+        { field         => 'is_broadband',
           type          => 'hidden',
-          value         => 'broadband'
+          value         => 'Y',
         },
         'description',
         { field         => 'active_date',
@@ -38,7 +38,7 @@
         'dbaname',
         { field         => 'technology',
           type          => 'select',
-          options       => [ keys(%$technology_labels) ],
+          options       => [ map { @$_ } values(%$media_types) ],
           labels        => $technology_labels,
         },
         { field         => 'is_consumer', type => 'checkbox', value=>'Y' },
@@ -71,6 +71,8 @@ die "access denied"
   ]);
 
 my $technology_labels = FS::part_pkg_fcc_option->technology_labels;
+my $media_types = FS::part_pkg_fcc_option->media_types;
+delete $media_types->{'Mobile Wireless'}; # cause this is the fixed zone page
 
 my $m2_error_callback = sub {
   my ($cgi, $deploy_zone) = @_;
@@ -78,6 +80,7 @@ my $m2_error_callback = sub {
     /^blocknum\d+/ and length($cgi->param($_.'_censusblock'))
   } $cgi->param;
 
+  sort { $a->censusblock <=> $b->censusblock }
   map {
     my $k = $_;
     FS::deploy_zone_block->new({
diff --git a/httemplate/edit/deploy_zone-fixed.html b/httemplate/edit/deploy_zone-mobile.html
similarity index 55%
copy from httemplate/edit/deploy_zone-fixed.html
copy to httemplate/edit/deploy_zone-mobile.html
index 8c6d54e..8e985b1 100644
--- a/httemplate/edit/deploy_zone-fixed.html
+++ b/httemplate/edit/deploy_zone-mobile.html
@@ -1,29 +1,24 @@
 <& elements/edit.html,
     'name_singular' => 'deployment zone',
     'table'         => 'deploy_zone',
-    'post_url'      => popurl(1).'process/deploy_zone-fixed.html',
+    'post_url'      => popurl(1).'process/deploy_zone-mobile.html',
     'labels'        => {
         'description'     => 'Description',
         'agentnum'        => 'Agent',
         'dbaname'         => 'Business name (if different from agent)',
         'technology'      => 'Technology',
+        'spectrum'        => 'Spectrum',
+        'is_broadband',   => 'Broadband Internet',
         'adv_speed_up'    => 'Upstream',
         'adv_speed_down'  => 'Downstream',
-        'cir_speed_up'    => 'Upstream',
-        'cir_speed_down'  => 'Downstream',
-        'is_consumer'     => 'Consumer/mass market',
-        'is_business'     => 'Business/government',
-        'blocknum'        => '',
+        'is_voice',       => 'Voice',
+        'vertexnum'       => '',
         'active_date'     => 'Active since',
     },
     'fields'        => [
         { field         => 'zonetype',
           type          => 'hidden',
-          value         => 'B'
-        },
-        { field         => 'servicetype',
-          type          => 'hidden',
-          value         => 'broadband'
+          value         => 'P'
         },
         'description',
         { field         => 'active_date',
@@ -38,24 +33,24 @@
         'dbaname',
         { field         => 'technology',
           type          => 'select',
-          options       => [ keys(%$technology_labels) ],
+          options       => $media_types->{'Mobile Wireless'},
           labels        => $technology_labels,
         },
-        { field         => 'is_consumer', type => 'checkbox', value=>'Y' },
-        { field         => 'is_business', type => 'checkbox', value=>'Y' },
+        { field         => 'spectrum',
+          type          => 'select',
+          options       => [ keys %$spectrum_labels ],
+          labels        => $spectrum_labels,
+        },
+        { field         => 'is_broadband', type => 'checkbox', value=>'Y' },
+        { field         => 'is_voice', type => 'checkbox', value=>'Y' },
         { type => 'tablebreak-tr-title',
-          value => 'Advertised maximum speed (Mbps)' },
+          value => 'Advertised minimum speed (Mbps)' },
         'adv_speed_down',
         'adv_speed_up',
-        { type => 'tablebreak-tr-title',
-          value => 'Contractually guaranteed speed (Mbps)' },
-        'cir_speed_down',
-        'cir_speed_up',
-
-        { type => 'tablebreak-tr-title', value => 'Census blocks'},
-        { field => 'blocknum',
-          type              => 'deploy_zone_block',
-          o2m_table         => 'deploy_zone_block',
+        { type => 'tablebreak-tr-title', value => 'Footprint'},
+        { field => 'vertexnum',
+          type              => 'deploy_zone_vertex',
+          o2m_table         => 'deploy_zone_vertex',
           m2_label          => ' ',
           m2_error_callback => $m2_error_callback,
         },
@@ -71,22 +66,25 @@ die "access denied"
   ]);
 
 my $technology_labels = FS::part_pkg_fcc_option->technology_labels;
+my $spectrum_labels = FS::part_pkg_fcc_option->spectrum_labels;
+my $media_types = FS::part_pkg_fcc_option->media_types;
 
 my $m2_error_callback = sub {
   my ($cgi, $deploy_zone) = @_;
-  my @blocknums = grep {
-    /^blocknum\d+/ and length($cgi->param($_.'_censusblock'))
+  my @vertexnums = sort { $a <=> $b } grep {
+    /^vertexnum\d+/ and length($cgi->param($_.'_latitude'))
   } $cgi->param;
 
   map {
     my $k = $_;
-    FS::deploy_zone_block->new({
-      blocknum    => scalar($cgi->param($k)),
+    my $s = 0;
+    FS::deploy_zone_vertex->new({
+      vertexnum   => scalar($cgi->param($k)),
       zonenum     => $deploy_zone->zonenum,
-      censusblock => scalar($cgi->param($k.'_censusblock')),
-      censusyear  => scalar($cgi->param($k.'_censusyear')),
+      latitude    => scalar($cgi->param($k.'_latitude')),
+      longitude   => scalar($cgi->param($k.'_longitude')),
     })
-  } @blocknums;
+  } @vertexnums;
 };
 
 </%init>
diff --git a/httemplate/edit/process/deploy_zone-mobile.html b/httemplate/edit/process/deploy_zone-mobile.html
new file mode 100644
index 0000000..c913c5c
--- /dev/null
+++ b/httemplate/edit/process/deploy_zone-mobile.html
@@ -0,0 +1,9 @@
+<& elements/process.html, 
+    error_redirect => popurl(2).'deploy_zone-mobile.html?',
+    table       => 'deploy_zone',
+    viewall_dir => 'browse',
+    process_o2m => 
+      { 'table'  => 'deploy_zone_vertex',
+                     'fields' => [qw( latitude longitude )]
+      },
+&>
diff --git a/httemplate/elements/deploy_zone_vertex.html b/httemplate/elements/deploy_zone_vertex.html
new file mode 100644
index 0000000..b3c8b31
--- /dev/null
+++ b/httemplate/elements/deploy_zone_vertex.html
@@ -0,0 +1,45 @@
+% unless ( $opt{'js_only'} ) {
+
+  <INPUT TYPE="hidden" NAME="<%$name%>" ID="<%$id%>" VALUE="<% $curr_value %>">
+  Latitude 
+  <INPUT TYPE  = "text"
+         NAME  = "<%$name%>_latitude"
+         ID    = "<%$id%>_latitude"
+         VALUE = "<% scalar($cgi->param($name.'_latitude'))
+                      || $deploy_zone_vertex->latitude
+                 %>"
+         SIZE  = 18
+         <% $onchange %>
+  >
+   
+  Longitude 
+  <INPUT TYPE  = "text"
+         NAME  = "<%$name%>_longitude"
+         ID    = "<%$id%>_longitude"
+         VALUE = "<% scalar($cgi->param($name.'_longitude'))
+                      || $deploy_zone_vertex->longitude
+                 %>"
+         SIZE  = 18
+         <% $onchange %>
+  >
+% }
+<%init>
+
+my( %opt ) = @_;
+
+my $name = $opt{'element_name'} || $opt{'field'} || 'vertexnum';
+my $id = $opt{'id'} || 'vertexnum';
+
+my $curr_value = $opt{'curr_value'} || $opt{'value'};
+
+my $onchange = $opt{'onchange'};
+if ( $onchange ) {
+  $onchange =~ s/\(what\);/(this);/;
+  $onchange = 'onchange="'.$onchange.'"';
+}
+
+my $deploy_zone_vertex = $curr_value
+  ? FS::deploy_zone_vertex->by_key($curr_value)
+  : FS::deploy_zone_vertex->new;
+
+</%init>
diff --git a/httemplate/misc/part_pkg_fcc_options.html b/httemplate/misc/part_pkg_fcc_options.html
index a5ecb12..27b45e0 100644
--- a/httemplate/misc/part_pkg_fcc_options.html
+++ b/httemplate/misc/part_pkg_fcc_options.html
@@ -92,10 +92,19 @@
     <FIELDSET ID="voip">
       <LABEL FOR="voip_sessions">Number of simultaneous calls possible</LABEL>
       <INPUT NAME="voip_sessions" ID="voip_sessions">
+      <BR>
       <& .checkbox, 'voip_lastmile' &>
       <LABEL FOR="voip_lastmile">Do you also provide last-mile connectivity?</LABEL>
     </FIELDSET>
   </P>
+  <P>
+    <& .checkbox, 'is_mobile' &>
+    <LABEL FOR="is_mobile">This package provides mobile telephone service</LABEL>
+    <FIELDSET ID="mobile">
+      <LABEL FOR="mobile_direct">Do you bill the customer directly?</LABEL>
+      <& .checkbox, 'mobile_direct' &>
+    </FIELDSET>
+  </P>
   <DIV WIDTH="100%" STYLE="text-align:center">
     <INPUT TYPE="submit" VALUE="Save changes">
   </DIV>
@@ -168,7 +177,7 @@ function enable_fieldset(fieldset_id) {
   // set up all event handlers
   addEventListener(form, 'submit', save_changes);
 
-  var sections = [ 'broadband', 'phone', 'voip' ];
+  var sections = [ 'broadband', 'phone', 'voip', 'mobile' ];
   for(var i = 0; i < sections.length; i++) {
     var toggle = form.elements['is_'+sections[i]];
     addEventListener(toggle, 'change', enable_fieldset(sections[i]));
diff --git a/httemplate/search/report_477.html b/httemplate/search/report_477.html
index 78ba35c..cbbd5d9 100755
--- a/httemplate/search/report_477.html
+++ b/httemplate/search/report_477.html
@@ -4,11 +4,13 @@
 %   $m->abort;
 % }
 <& /elements/header.html, 'FCC Form 477 Report' &>
+% if ( $curuser->access_right('Edit FCC report configuration') ) {
 <FONT SIZE="+1"><STRONG>Preparation</STRONG></FONT>
 <UL>
   <LI> <A HREF="<% $p %>browse/part_pkg-fcc.html">Configure packages</A> for FCC reporting categories.</LI>
   <LI> <A HREF="<% $p %>browse/deploy_zone.html">Enter deployment zones</A> for broadband Internet or mobile phone.</LI>
 </UL>
+% }
   
 <FORM ACTION="477.html" METHOD="GET">
 
@@ -48,8 +50,9 @@
 <& /elements/footer.html &>
 <%init>
 
+my $curuser = $FS::CurrentUser::CurrentUser;
 die "access denied"
-  unless $FS::CurrentUser::CurrentUser->access_right('List packages');
+  unless $curuser->access_right('List packages');
 
 my $conf = FS::Conf->new;
 

commit d7cf0d6bb3b81b1c91ef1bcc3252d56f96b65b0f
Author: Mark Wells <mark at freeside.biz>
Date:   Tue Aug 5 15:54:51 2014 -0700

    477 report: improve browse-edit UI

diff --git a/FS/FS/Report/FCC_477.pm b/FS/FS/Report/FCC_477.pm
index bf4754d..86fa0a6 100644
--- a/FS/FS/Report/FCC_477.pm
+++ b/FS/FS/Report/FCC_477.pm
@@ -8,6 +8,9 @@ use vars qw( @upload @download @technology @part2aoption @part2boption
            );
 use FS::Record qw( dbh );
 
+use Tie::IxHash;
+use Storable;
+
 $DEBUG = 1;
 
 =head1 NAME
@@ -213,6 +216,8 @@ sub statenum2state {
 
 =cut
 
+# functions for internal use
+
 sub join_optionnames {
   join(' ', map { join_optionname($_) } @_);
 }
@@ -259,25 +264,69 @@ sub is_fixed_broadband {
   ).")";
 }
 
-=item report_fixed_broadband OPTIONS
-
-Returns the Fixed Broadband Subscription report (section 5.4), as an arrayref
-of an arrayrefs.  OPTIONS may contain:
-- date: a timestamp value to count active packages as of that date
-- agentnum: limit to customers of that agent
+=item report SECTION, OPTIONS
 
-Columns of this report are:
-- census tract
-- technology code
-- downstream speed
-- upstream speed
-(the above columns form a key)
-- number of subscriptions
-- number of consumer-grade subscriptions
+Returns the report section SECTION (see the C<parts> method for section 
+name strings) as an arrayref of arrayrefs.  OPTIONS may contain "date"
+(a timestamp value to run the report as of this date) and "agentnum"
+(to limit to a single agent).
 
 =cut
 
-sub report_fixed_broadband {
+sub report {
+  my $class = shift;
+  my $section = shift;
+  my %opt = @_;
+
+  my $method = $section.'_sql';
+  die "Report section '$section' is not implemented\n"
+    unless $class->can($method);
+  my $statement = $class->$method(%opt);
+
+  my $sth = dbh->prepare($statement);
+  $sth->execute or die $sth->errstr;
+  $sth->fetchall_arrayref;
+}
+
+sub fbd_sql {
+  my $class = shift;
+  my %opt = shift;
+  my $date = $opt{date} || time;
+  warn $date;
+  my $agentnum = $opt{agentnum};
+
+  my @select = (
+    'censusblock',
+    'COALESCE(dbaname, agent.agent)',
+    'technology',
+    'CASE WHEN is_consumer IS NOT NULL THEN 1 ELSE 0 END',
+    'adv_speed_down',
+    'adv_speed_up',
+    'CASE WHEN is_business IS NOT NULL THEN 1 ELSE 0 END',
+    'cir_speed_down',
+    'cir_speed_up',
+  );
+  my $from =
+    'deploy_zone_block
+    JOIN deploy_zone USING (zonenum)
+    JOIN agent USING (agentnum)';
+  my @where = (
+    "zonetype = 'B'",
+    "active_date  < $date",
+    "(expire_date > $date OR expire_date IS NULL)",
+  );
+  push @where, "agentnum = $agentnum" if $agentnum;
+
+  my $order_by = 'censusblock, dbaname, technology, is_consumer, is_business';
+
+  "SELECT ".join(', ', @select) . "
+  FROM $from
+  WHERE ".join(' AND ', @where)."
+  ORDER BY $order_by
+  ";
+}
+
+sub fbs_sql {
   my $class = shift;
   my %opt = shift;
   my $date = $opt{date} || time;
@@ -311,31 +360,16 @@ sub report_fixed_broadband {
                    'broadband_downstream, broadband_upstream ';
   my $order_by = $group_by;
 
-  my $statement = "SELECT ".join(', ', @select) . "
+  "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 report_fixed_voice OPTIONS
-
-Returns the Fixed Voice Subscription Detail report (section 5.5).  OPTIONS
-are as above.  Columns are:
-
-- census tract
-- service type (0 for non-VoIP, 1 for VoIP)
-(the above columns form a key)
-- VGE lines/VoIP subscriptions in service
-- consumer grade VGE lines/VoIP subscriptions
-
-=cut
-
-sub report_fixed_voice {
+sub fvs_sql {
   my $class = shift;
   my %opt = shift;
   my $date = $opt{date} || time;
@@ -368,41 +402,16 @@ sub report_fixed_voice {
   my $group_by = 'cust_location.censustract, COALESCE(is_voip, 0)';
   my $order_by = $group_by;
 
-  my $statement = "SELECT ".join(', ', @select) . "
+  "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 report_local_phone OPTIONS
-
-Returns the Local Exchange Telephone Subscription report (section 5.6).  
-OPTIONS are as above.  Each row is data for one state.  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
-
-sub report_local_phone {
+sub lts_sql {
   my $class = shift;
   my %opt = shift;
   my $date = $opt{date} || time;
@@ -446,37 +455,15 @@ sub report_local_phone {
   my $group_by = 'state.fips';
   my $order_by = $group_by;
 
-  my $statement = "SELECT ".join(', ', @select) . "
+  "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 report_voip OPTIONS
-
-Returns the Interconnected VoIP Subscription report (section 5.7).  
-OPTIONS are as above.  Columns are:
-
-- state FIPS code (key)
-- OTT subscriptions (non-last-mile)
-- OTT subscriptions sold to consumers
-- last-mile subscriptions
-- last-mile subscriptions sold to consumers
-- last-mile subscriptions bundled with broadband Internet
-- last-mile subscriptions over copper pairs
-- last-mile subscriptions over coaxial
-- last-mile subscriptions over fiber
-- last-mile subscriptions over fixed wireless
-- last-mile subscriptions over other media
-
-=cut
-
-sub report_voip {
+sub voip_sql {
   my $class = shift;
   my %opt = shift;
   my $date = $opt{date} || time;
@@ -517,16 +504,37 @@ sub report_voip {
   my $group_by = 'state.fips';
   my $order_by = $group_by;
 
-  my $statement = "SELECT ".join(', ', @select) . "
+  "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 parts
+
+Returns a Tie::IxHash reference of the internal short names used for the 
+report sections ('fbd', 'mbs', etc.) to the full names.
+
+=cut
+
+tie our %parts, 'Tie::IxHash', (
+  fbd   => 'Fixed Broadband Deployment',
+  fbs   => 'Fixed Broadband Subscription',
+  fvs   => 'Fixed Voice Subscription',
+  lts   => 'Local Exchange Telephone Subscription',
+  voip  => 'Interconnected VoIP Subscription',
+  mbd   => 'Mobile Broadband Deployment',
+  mbsa  => 'Mobile Broadband Service Availability',
+  mbs   => 'Mobile Broadband Subscription',
+  mvd   => 'Mobile Voice Deployment',
+  mvs   => 'Mobile Voice Subscription',
+);
+
+sub parts {
+  Storable::dclone(\%parts);
+}
 
 1;
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 830b39a..2b6dc6d 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -6683,12 +6683,14 @@ sub tables_hashref {
         'technology',     'int',     '',     '',      '', '',
         'spectrum',       'int',     'NULL', '',      '', '',
         'servicetype',    'char',    '',     '12',    '', '',
-        'adv_speed_up',   'decimal', 'NULL', '10,3',  '', '',
-        'adv_speed_down', 'decimal', 'NULL', '10,3',  '', '',
-        'cir_speed_up',   'decimal', 'NULL', '10,3',  '', '',
-        'cir_speed_down', 'decimal', 'NULL', '10,3',  '', '',
+        'adv_speed_up',   'decimal', '',     '10,3', '0', '',
+        'adv_speed_down', 'decimal', '',     '10,3', '0', '',
+        'cir_speed_up',   'decimal', '',     '10,3', '0', '',
+        'cir_speed_down', 'decimal', '',     '10,3', '0', '',
         'is_consumer',    'char',    'NULL', 1,       '', '',
         'is_business',    'char',    'NULL', 1,       '', '',
+        'active_date',    @date_type,                 '', '',
+        'expire_date',    @date_type,                 '', '',
       ],
       'primary_key' => 'zonenum',
       'unique' => [],
diff --git a/FS/FS/deploy_zone.pm b/FS/FS/deploy_zone.pm
index 3caeda2..227a022 100644
--- a/FS/FS/deploy_zone.pm
+++ b/FS/FS/deploy_zone.pm
@@ -97,6 +97,14 @@ type of service is sold.
 'Y' if this service is sold to business or institutional use.  Not mutually
 exclusive with is_consumer.
 
+=item active_date
+
+The date this zone became active.
+
+=item expire_date
+
+The date this zone became inactive, if any.
+
 =back
 
 =head1 METHODS
@@ -183,9 +191,20 @@ sub check {
     || $self->ut_decimaln('cir_speed_down', 3)
     || $self->ut_flag('is_consumer')
     || $self->ut_flag('is_business')
+    || $self->ut_numbern('active_date')
+    || $self->ut_numbern('expire_date')
   ;
   return $error if $error;
 
+  foreach(qw(adv_speed_down adv_speed_up cir_speed_down cir_speed_up)) {
+    if (!$self->get($_)) {
+      $self->set($_, 0);
+    }
+  }
+  if (!$self->get('active_date')) {
+    $self->set('active_date', time);
+  }
+
   $self->SUPER::check;
 }
 
diff --git a/FS/FS/part_pkg.pm b/FS/FS/part_pkg.pm
index 741eb87..06f304a 100644
--- a/FS/FS/part_pkg.pm
+++ b/FS/FS/part_pkg.pm
@@ -338,7 +338,7 @@ sub insert {
 
   if ( $options{fcc_options} ) {
     warn "  updating fcc options " if $DEBUG;
-    $self->process_fcc_options( $options{fcc_options} );
+    $self->set_fcc_options( $options{fcc_options} );
   }
 
   warn "  committing transaction" if $DEBUG and $oldAutoCommit;
@@ -624,7 +624,7 @@ sub replace {
 
   if ( $options->{fcc_options} ) {
     warn "  updating fcc options " if $DEBUG;
-    $new->process_fcc_options( $options->{fcc_options} );
+    $new->set_fcc_options( $options->{fcc_options} );
   }
 
   warn "  committing transaction" if $DEBUG and $oldAutoCommit;
@@ -787,14 +787,14 @@ sub propagate {
   join("\n", @error);
 }
 
-=item process_fcc_options HASHREF
+=item set_fcc_options HASHREF
 
 Sets the FCC options on this package definition to the values specified
 in HASHREF.
 
 =cut
 
-sub process_fcc_options {
+sub set_fcc_options {
   my $self = shift;
   my $pkgpart = $self->pkgpart;
   my $options;
diff --git a/httemplate/browse/part_pkg-fcc.html b/httemplate/browse/part_pkg-fcc.html
index 9462c32..9facd10 100755
--- a/httemplate/browse/part_pkg-fcc.html
+++ b/httemplate/browse/part_pkg-fcc.html
@@ -3,14 +3,13 @@
   'menubar'               => \@menubar,
   'html_init'             => $html_init,
   'html_form'             => $html_form,
-  'html_posttotal'        => $html_posttotal,
   'name'                  => 'package definitions',
   'disableable'           => 1,
   'disabled_statuspos'    => 4,
   'agent_virt'            => 1,
   'agent_null_right'      => [ $edit, $edit_global ],
   'agent_null_right_link' => $edit_global,
-  'agent_pos'             => 6,
+  'agent_pos'             => 3,
   'query'                 =>
                             { 'select'    => $select,
                               'table'     => 'part_pkg',
@@ -39,6 +38,14 @@ my $acl_edit_global = $curuser->access_right($edit_global);
 die "access denied"
   unless $acl_edit || $acl_edit_global;
 
+if ( $cgi->param('redirect') ) {
+  my $session = $cgi->param('redirect');
+  my $pref = $curuser->option("redirect$session");
+  die "unknown redirect session $session\n" unless length($pref);
+  $cgi = new CGI($pref);
+  $cgi->param('redirect', $session);
+}
+
 my $conf = new FS::Conf;
 
 my $orderby = 'pkgpart';
@@ -88,22 +95,8 @@ my $select = join(',',
 my $addl_from = 
   FS::Report::FCC_477::join_optionnames(@optionnames);
 
-#restore this so pagination works
 $cgi->param('classnum', $classnum) if length($classnum);
 
-#should hide this if there aren't any classes
-my $html_posttotal =
-  "<BR>( show class: ".
-  include('/elements/select-pkg_class.html',
-            #'curr_value'    => $classnum,
-            'value'         => $classnum, #insist on 0 :/
-            'onchange'      => 'filter_change()',
-            'pre_options'   => [ '-1' => 'all',
-                                 '0'  => '(none)', ],
-            'disable_empty' => 1,
-         ).
-  ' )';
-
 my $link = [ $p.'edit/part_pkg.cgi?', 'pkgpart' ];
 
 my @header = ( '#', 'Package', 'Comment' );
@@ -176,24 +169,42 @@ $extra_count = ( $count_extra_sql ? ' AND ' : ' WHERE ' ). $extra_count
   if $extra_count;
 my $count_query = "SELECT COUNT(*) FROM part_pkg $count_extra_sql $extra_count";
 
+# in case of error redirect
+if ( $cgi->param('redirect') ) {
+  push @header, '';
+  push @fields, sub {
+    my $part_pkg = shift;
+    my $pkgpart = $part_pkg->pkgpart;
+    '<B><FONT COLOR="#ffffff">' . $cgi->param("error$pkgpart") || '' . '</FONT></B>'
+  };
+  $align .= 'l';
+}
+
 my $html_init = 
   include('/elements/init_overlib.html') .
   include('/elements/input-fcc_options.html', js_only => 1) .
   include('.style');
 
-my $html_form = '';
-my $html_foot = '';
-# insert a checkbox column
-unshift @header, '';
-unshift @fields, sub {
-  '<INPUT TYPE="checkbox" NAME="pkgpart" VALUE=' . $_[0]->pkgpart .'>';
-};
-unshift @links, '';
-$align = 'c'.$align;
-
+my $html_form = qq!<FORM ACTION="${p}edit/process/bulk-part_pkg-fcc.html" METHOD="POST">
+  ( show class: !.
+  include('/elements/select-pkg_class.html',
+            #'curr_value'    => $classnum,
+            'value'         => $classnum, #insist on 0 :/
+            'onchange'      => 'filter_change()',
+            'pre_options'   => [ '-1' => 'all',
+                                 '0'  => '(none)', ],
+            'disable_empty' => 1,
+         ).
+  ' )
+  <BR><BR>' .
+  qq!<SCRIPT TYPE="text/javascript">
+  function filter_change() {
+    window.location = '! . $cgi->self_url . qq!?classnum='
+      + document.getElementById('classnum').value;
+  }
+  </SCRIPT>!;
 
-$html_form = qq!<FORM ACTION="${p}edit/process/bulk-part_pkg-fcc.html" METHOD="POST">!;
-$html_foot = qq!
+my $html_foot = qq!
   <INPUT TYPE="submit" VALUE="Save changes">
   </FORM>!;
 
diff --git a/httemplate/edit/deploy_zone-fixed.html b/httemplate/edit/deploy_zone-fixed.html
index ecec9c4..8c6d54e 100644
--- a/httemplate/edit/deploy_zone-fixed.html
+++ b/httemplate/edit/deploy_zone-fixed.html
@@ -14,6 +14,7 @@
         'is_consumer'     => 'Consumer/mass market',
         'is_business'     => 'Business/government',
         'blocknum'        => '',
+        'active_date'     => 'Active since',
     },
     'fields'        => [
         { field         => 'zonetype',
@@ -25,6 +26,10 @@
           value         => 'broadband'
         },
         'description',
+        { field         => 'active_date',
+          type          => 'fixed-date',
+          value         => time,
+        },
         { field         => 'agentnum',
           type          => 'select-agent',
           disable_empty => 1,
diff --git a/httemplate/edit/process/bulk-part_pkg-fcc.html b/httemplate/edit/process/bulk-part_pkg-fcc.html
new file mode 100644
index 0000000..17579aa
--- /dev/null
+++ b/httemplate/edit/process/bulk-part_pkg-fcc.html
@@ -0,0 +1,42 @@
+% if ( keys %error ) {
+%   foreach my $pkgpart (keys %error) {
+%     # stuff all the errors back into $cgi
+%     $cgi->param("error$pkgpart", $error{$pkgpart});
+%   }
+%   my $session = int(rand(4294967296)); #XXX
+%   my $pref = new FS::access_user_pref({
+%     'usernum'    => $FS::CurrentUser::CurrentUser->usernum,
+%     'prefname'   => "redirect$session",
+%     'prefvalue'  => $cgi->query_string,
+%     'expiration' => time + 3600, #1h?  1m?
+%   });
+%   my $pref_error = $pref->insert;
+%   if ( $pref_error ) {
+%     die "FATAL: couldn't even set redirect cookie: $pref_error".
+%         " attempting to set redirect$session to ". $cgi->query_string."\n";
+%   }
+<% $cgi->redirect($fsurl.'browse/part_pkg-fcc.html?redirect='.$session) %>
+% } else {
+<% $cgi->redirect($fsurl.'browse/part_pkg-fcc.html?classnum='.$classnum) %>
+% }
+<%init>
+my $curuser = $FS::CurrentUser::CurrentUser;
+die "access denied"
+  unless $curuser->access_right('Bulk edit package definitions');
+
+# non-atomic; report errors but allow successful changes to go through
+# not that I even know how you'd get an error doing this
+
+my %error;
+foreach my $param ($cgi->param) {
+  $param =~ /^pkgpart(\d+)$/ or next;
+  my $pkgpart = $1;
+  my $part_pkg = FS::part_pkg->by_key($pkgpart);
+  my $hashref = decode_json( $cgi->param($param) );
+  my $error = $part_pkg->set_fcc_options($hashref);
+  $error{$pkgpart} = $error if $error;
+}
+
+my $classnum = $cgi->param('classnum');
+
+</%init>
diff --git a/httemplate/elements/input-fcc_options.html b/httemplate/elements/input-fcc_options.html
index b191e1c..1d56cf2 100644
--- a/httemplate/elements/input-fcc_options.html
+++ b/httemplate/elements/input-fcc_options.html
@@ -39,7 +39,7 @@ function show_fcc_options(id) {
   }
   var media = String.toLowerCase(curr_values['media'] || 'unknown media');
   if ( curr_values['is_consumer'] ) {
-    out += '<li><strong>Consumer-grade</strong> service</li>>';
+    out += '<li><strong>Consumer-grade</strong> service</li>';
   } else {
     out += '<li><strong>Business-grade</strong> service</li>';
   }
diff --git a/httemplate/search/477.html b/httemplate/search/477.html
index 26bd9f3..fb85f1e 100644
--- a/httemplate/search/477.html
+++ b/httemplate/search/477.html
@@ -41,7 +41,7 @@ a.download {
 %   $cgi->param('type', 'csv');
 <table class="fcc477part">
   <caption>
-    <span class="parttitle"><% $parttitle{$partname} %></span>
+    <span class="parttitle"><% $part_titles->{$partname} %></span>
     <a class="download" href="<% $cgi->self_url %>">Download</a>
   </caption>
 %   my $header = ".header_$partname";
@@ -81,8 +81,7 @@ if ($cgi->param('agentnum') =~ /^(\d+)$/ ) {
 my $date = parse_datetime($cgi->param('date')) || time;
 my @partnames = grep /^\w+$/, $cgi->param('parts');
 foreach my $partname (@partnames) {
-  my $method = "report_$partname";
-  $parts{$partname} ||= FS::Report::FCC_477->$method(
+  $parts{$partname} ||= FS::Report::FCC_477->report( $partname,
     date      => $date,
     agentnum  => $agentnum
   );
@@ -109,8 +108,27 @@ if ( $cgi->param('type') eq 'csv' ) {
   $m->abort;
 }
 
+my $part_titles = FS::Report::FCC_477->parts;
+
 </%init>
-<%def .header_fixed_broadband>
+<%def .header_fbd>
+  <TR CLASS="head">
+    <TD ROWSPAN=2>Census Block</TD>
+    <TD ROWSPAN=2>DBA Name</TD>
+    <TD ROWSPAN=2>Technology</TD>
+    <TD ROWSPAN=2>Consumer?</TD>
+    <TD COLSPAN=2>Advertised Speed (Mbps)</TD>
+    <TD ROWSPAN=2>Business?</TD>
+    <TD COLSPAN=2>Contractual Speed (Mbps)</TD>
+  </TR>
+  <TR CLASS="subhead">
+    <TD>Down</TD>
+    <TD>Up</TD>
+    <TD>Down</TD>
+    <TD>Up</TD>
+  </TR>
+</%def>
+<%def .header_fbs>
   <TR CLASS="head">
     <TD ROWSPAN=2>Census Tract</TD>
     <TD ROWSPAN=2>Technology</TD>
@@ -124,7 +142,7 @@ if ( $cgi->param('type') eq 'csv' ) {
     <TD>Consumer</TD>
   </TR>
 </%def>
-<%def .header_fixed_voice>
+<%def .header_fvs>
   <TR CLASS="head">
     <TD ROWSPAN=2>Census Tract</TD>
     <TD ROWSPAN=2>VoIP?</TD>
@@ -135,7 +153,7 @@ if ( $cgi->param('type') eq 'csv' ) {
     <TD>Consumer</TD>
   </TR>
 </%def>
-<%def .header_local_phone>
+<%def .header_lts>
   <TR CLASS="head">
     <TD ROWSPAN=3>State</TD>
     <TD COLSPAN=2>Wholesale</TD>
@@ -193,7 +211,7 @@ if ( $cgi->param('type') eq 'csv' ) {
     <TD>Other</TD>
   </TR>
 </%def>
-<%def .header_mobile_broadband>
+<%def .header_mbs>
 %# unimplemented
   <TR CLASS="head">
     <TD ROWSPAN=2>State</TD>
@@ -207,7 +225,7 @@ if ( $cgi->param('type') eq 'csv' ) {
     <TD>Consumer</TD>
   </TR>
 </%def>
-<%def .header_mobile_voice>
+<%def .header_mvs>
 %# unimplemented
   <TR CLASS="head">
     <TD ROWSPAN=2>State</TD>
diff --git a/httemplate/search/report_477.html b/httemplate/search/report_477.html
index 2a6878e..78ba35c 100755
--- a/httemplate/search/report_477.html
+++ b/httemplate/search/report_477.html
@@ -4,7 +4,12 @@
 %   $m->abort;
 % }
 <& /elements/header.html, 'FCC Form 477 Report' &>
-
+<FONT SIZE="+1"><STRONG>Preparation</STRONG></FONT>
+<UL>
+  <LI> <A HREF="<% $p %>browse/part_pkg-fcc.html">Configure packages</A> for FCC reporting categories.</LI>
+  <LI> <A HREF="<% $p %>browse/deploy_zone.html">Enter deployment zones</A> for broadband Internet or mobile phone.</LI>
+</UL>
+  
 <FORM ACTION="477.html" METHOD="GET">
 
   <TABLE BGCOLOR="#cccccc" CELLSPACING=0>
@@ -30,15 +35,8 @@
     <& /elements/tr-checkbox-multiple.html,
       'label'   => 'Enable parts',
       'field'   => 'parts',
-      'labels'  => {
-        fixed_broadband => 'Fixed Broadband Subscription',
-        #7   => 'Part 7 (Mobile Wireless Broadband Subscription),
-        #8   => 'Part 8 (Mobile Local Telephone Subscription),
-        fixed_voice     => 'Voice Telephone Subscription',
-        local_phone     => 'Local Exchange Telephone Subscription',
-        voip            => 'Interconnected VoIP Subscription',
-      },
-      options => [ 6, 9, 10, 11 ],
+      'labels'  => $part_titles,
+      'options' => [ keys %$part_titles ]
     &>
   </TABLE>
 
@@ -54,4 +52,7 @@ die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('List packages');
 
 my $conf = FS::Conf->new;
+
+my $part_titles = FS::Report::FCC_477->parts;
+
 </%init>

commit 0f359d5480aa1621d73ee802f420e8951abc620d
Author: Mark Wells <mark at freeside.biz>
Date:   Thu Jul 31 22:54:08 2014 -0700

    new 477 report: deployment info, combined browse-edit UI, #24047

diff --git a/FS/FS/AccessRight.pm b/FS/FS/AccessRight.pm
index 9b9642e..8d4d67b 100644
--- a/FS/FS/AccessRight.pm
+++ b/FS/FS/AccessRight.pm
@@ -359,6 +359,9 @@ tie my %rights, 'Tie::IxHash',
 
     'Bulk edit package definitions',
 
+    'Edit FCC report configuration',
+    { rightname => 'Edit FCC report configuration for all agents', global=>1 },
+
     'Edit CDR rates',
     #{ rightname=>'Edit global CDR rates', global=>1, },
 
diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm
index 4f43c6b..93eca5e 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -387,6 +387,9 @@ if ( -e $addl_handler_use_file ) {
   use FS::state;
   use FS::state;
   use FS::queue_stat;
+  use FS::deploy_zone;
+  use FS::deploy_zone_block;
+  use FS::deploy_zone_vertex;
   # Sammath Naur
 
   if ( $FS::Mason::addl_handler_use ) {
diff --git a/FS/FS/Record.pm b/FS/FS/Record.pm
index 24f2a25..2005756 100644
--- a/FS/FS/Record.pm
+++ b/FS/FS/Record.pm
@@ -124,6 +124,8 @@ FS::Record - Database record objects
     $error = $record->ut_floatn('column');
     $error = $record->ut_number('column');
     $error = $record->ut_numbern('column');
+    $error = $record->ut_decimal('column');
+    $error = $record->ut_decimaln('column');
     $error = $record->ut_snumber('column');
     $error = $record->ut_snumbern('column');
     $error = $record->ut_money('column');
@@ -2434,6 +2436,35 @@ sub ut_numbern {
   '';
 }
 
+=item ut_decimal COLUMN[, DIGITS]
+
+Check/untaint decimal numbers (up to DIGITS decimal places.  If there is an 
+error, returns the error, otherwise returns false.
+
+=item ut_decimaln COLUMN[, DIGITS]
+
+Check/untaint decimal numbers.  May be null.  If there is an error, returns
+the error, otherwise returns false.
+
+=cut
+
+sub ut_decimal {
+  my($self, $field, $digits) = @_;
+  $digits ||= '';
+  $self->getfield($field) =~ /^\s*(\d+(\.\d{0,$digits})?)\s*$/
+    or return "Illegal or empty (decimal) $field: ".$self->getfield($field);
+  $self->setfield($field, $1);
+  '';
+}
+
+sub ut_decimaln {
+  my($self, $field, $digits) = @_;
+  $self->getfield($field) =~ /^\s*(\d*(\.\d{0,$digits})?)\s*$/
+    or return "Illegal (decimal) $field: ".$self->getfield($field);
+  $self->setfield($field, $1);
+  '';
+}
+
 =item ut_money COLUMN
 
 Check/untaint monetary numbers.  May be negative.  Set to 0 if null.  If there
diff --git a/FS/FS/Report/FCC_477.pm b/FS/FS/Report/FCC_477.pm
index 79f00e3..bf4754d 100644
--- a/FS/FS/Report/FCC_477.pm
+++ b/FS/FS/Report/FCC_477.pm
@@ -165,8 +165,6 @@ sub save_fcc477map {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  # lame (should be normal FS::Record access)
-
   my $sql = "delete from fcc477map where formkey = ?";
   my $sth = dbh->prepare($sql) or die dbh->errstr;
   $sth->execute($key) or do {
@@ -204,6 +202,8 @@ sub statenum2state {
   my $num = shift;
   $states{$num};
 }
+### everything above this point is unmaintained ###
+
 
 =head1 THE "NEW" REPORT (October 2014 and later)
 
@@ -259,15 +259,14 @@ sub is_fixed_broadband {
   ).")";
 }
 
-=item part6 OPTIONS
+=item report_fixed_broadband OPTIONS
 
-Returns Part 6 of the 2014 FCC 477 data, as an arrayref of arrayrefs.
-OPTIONS may contain:
+Returns the Fixed Broadband Subscription report (section 5.4), as an arrayref
+of an arrayrefs.  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:
+Columns of this report are:
 - census tract
 - technology code
 - downstream speed
@@ -278,7 +277,7 @@ report are:
 
 =cut
 
-sub part6 {
+sub report_fixed_broadband {
   my $class = shift;
   my %opt = shift;
   my $date = $opt{date} || time;
@@ -323,10 +322,67 @@ sub part6 {
   dbh->selectall_arrayref($statement);
 }
 
-=item part9 OPTIONS
+=item report_fixed_voice OPTIONS
+
+Returns the Fixed Voice Subscription Detail report (section 5.5).  OPTIONS
+are as above.  Columns are:
+
+- census tract
+- service type (0 for non-VoIP, 1 for VoIP)
+(the above columns form a key)
+- VGE lines/VoIP subscriptions in service
+- consumer grade VGE lines/VoIP subscriptions
+
+=cut
+
+sub report_fixed_voice {
+  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 voip_sessions 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 voip_sessions
+      ))
+  ;
+
+  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);
+}
+
+=item report_local_phone OPTIONS
 
-Returns Part 9 of the 2014 FCC 477 data.  Part 9 is the Local Exchange 
-Telephone Subscription report.  Columns are:
+Returns the Local Exchange Telephone Subscription report (section 5.6).  
+OPTIONS are as above.  Each row is data for one state.  Columns are:
 
 - state FIPS code (key)
 - wholesale switched voice lines
@@ -346,7 +402,7 @@ Telephone Subscription report.  Columns are:
 
 =cut
 
-sub part9 {
+sub report_local_phone {
   my $class = shift;
   my %opt = shift;
   my $date = $opt{date} || time;
@@ -358,10 +414,10 @@ sub part9 {
     "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 is_consumer = 1 AND phone_longdistance IS NULL THEN phone_lines ELSE 0 END)",
+    "SUM(CASE WHEN is_consumer = 1 AND phone_longdistance = 1 THEN phone_lines ELSE 0 END)",
+    "SUM(CASE WHEN is_consumer IS NULL AND phone_longdistance IS NULL THEN phone_lines ELSE 0 END)",
+    "SUM(CASE WHEN is_consumer IS NULL AND phone_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)",
@@ -378,7 +434,7 @@ sub part9 {
       join_optionnames_int(qw(
         is_phone is_broadband
         phone_vges phone_circuits phone_lines
-        is_consumer is_longdistance
+        is_consumer phone_longdistance
         )).
       join_optionnames('media', 'phone_localloop')
   ;
@@ -401,7 +457,26 @@ sub part9 {
   dbh->selectall_arrayref($statement);
 }
 
-sub part10 {
+=item report_voip OPTIONS
+
+Returns the Interconnected VoIP Subscription report (section 5.7).  
+OPTIONS are as above.  Columns are:
+
+- state FIPS code (key)
+- OTT subscriptions (non-last-mile)
+- OTT subscriptions sold to consumers
+- last-mile subscriptions
+- last-mile subscriptions sold to consumers
+- last-mile subscriptions bundled with broadband Internet
+- last-mile subscriptions over copper pairs
+- last-mile subscriptions over coaxial
+- last-mile subscriptions over fiber
+- last-mile subscriptions over fixed wireless
+- last-mile subscriptions over other media
+
+=cut
+
+sub report_voip {
   my $class = shift;
   my %opt = shift;
   my $date = $opt{date} || time;
@@ -453,54 +528,5 @@ sub part10 {
   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 27bd813..830b39a 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -6672,6 +6672,75 @@ sub tables_hashref {
       'index' => [],
     },
 
+    # eventually link to tower/sector?
+    'deploy_zone' => {
+      'columns' => [
+        'zonenum',        'serial',  '',     '',      '', '',
+        'description',    'char',    'NULL', $char_d, '', '',
+        'agentnum',       'int',     '',     '',      '', '',
+        'dbaname',        'char',    'NULL', $char_d, '', '',
+        'zonetype',       'char',    '',     1,       '', '',
+        'technology',     'int',     '',     '',      '', '',
+        'spectrum',       'int',     'NULL', '',      '', '',
+        'servicetype',    'char',    '',     '12',    '', '',
+        'adv_speed_up',   'decimal', 'NULL', '10,3',  '', '',
+        'adv_speed_down', 'decimal', 'NULL', '10,3',  '', '',
+        'cir_speed_up',   'decimal', 'NULL', '10,3',  '', '',
+        'cir_speed_down', 'decimal', 'NULL', '10,3',  '', '',
+        'is_consumer',    'char',    'NULL', 1,       '', '',
+        'is_business',    'char',    'NULL', 1,       '', '',
+      ],
+      'primary_key' => 'zonenum',
+      'unique' => [],
+      'index'  => [ [ 'agentnum' ] ],
+      'foreign_keys' => [
+                          { columns     => [ 'agentnum' ],
+                            table       => 'agent',
+                            references  => [ 'agentnum' ],
+                          },
+                        ],
+    },
+
+    'deploy_zone_block' => {
+      'columns' => [
+        'blocknum',       'serial',  '',     '',      '', '',
+        'zonenum',        'int',     '',     '',      '', '',
+        'censusblock',    'char',    '',     15,      '', '',
+        'censusyear',     'char',    '',      4,      '', '',
+      ],
+      'primary_key' => 'blocknum',
+      'unique' => [],
+      'index'  => [ [ 'zonenum' ] ],
+      'foreign_keys' => [
+                          { columns     => [ 'zonenum' ],
+                            table       => 'deploy_zone',
+                            references  => [ 'zonenum' ],
+                          },
+                        ],
+    },
+
+    'deploy_zone_vertex' => {
+      'columns' => [
+        'vertexnum',      'serial',  '',     '',      '', '',
+        'zonenum',        'int',     '',     '',      '', '',
+        'latitude',       'decimal', '',     '10,7',  '', '', 
+        'longitude',      'decimal', '',     '10,7',  '', '', 
+        'sequence',       'int',     '',     '',      '', '',
+      ],
+      'primary_key' => 'vertexnum',
+      'unique' => [ [ 'zonenum', 'sequence' ] ],
+      'index'  => [ ],
+      'foreign_keys' => [
+                          { columns     => [ 'zonenum' ],
+                            table       => 'deploy_zone',
+                            references  => [ 'zonenum' ],
+                          },
+                        ],
+    },
+
+
+
+
 
     # name type nullability length default local
 
diff --git a/FS/FS/deploy_zone.pm b/FS/FS/deploy_zone.pm
new file mode 100644
index 0000000..3caeda2
--- /dev/null
+++ b/FS/FS/deploy_zone.pm
@@ -0,0 +1,221 @@
+package FS::deploy_zone;
+
+use strict;
+use base qw( FS::o2m_Common FS::Record );
+use FS::Record qw( qsearch qsearchs dbh );
+
+=head1 NAME
+
+FS::deploy_zone - Object methods for deploy_zone records
+
+=head1 SYNOPSIS
+
+  use FS::deploy_zone;
+
+  $record = new FS::deploy_zone \%hash;
+  $record = new FS::deploy_zone { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::deploy_zone object represents a geographic zone where a certain kind
+of service is available.  Currently we store this information to generate
+the FCC Form 477 deployment reports, but it may find other uses later.
+
+FS::deploy_zone inherits from FS::Record.  The following fields are currently
+supported:
+
+=over 4
+
+=item zonenum
+
+primary key
+
+=item description
+
+Optional text describing the zone.
+
+=item agentnum
+
+The agent that serves this zone.
+
+=item dbaname
+
+The name under which service is marketed in this zone.  If null, will 
+default to the agent name.
+
+=item zonetype
+
+The way the zone geography is defined: "B" for a list of census blocks
+(used by the FCC for fixed broadband service), "P" for a polygon (for 
+mobile services).  See L<FS::deploy_zone_block> and L<FS::deploy_zone_vertex>.
+
+=item technology
+
+The FCC technology code for the type of service available.
+
+=item spectrum
+
+For mobile service zones, the FCC code for the RF band.
+
+=item servicetype
+
+"broadband" or "voice"
+
+=item adv_speed_up
+
+For broadband, the advertised upstream bandwidth in the zone.  If multiple
+speed tiers are advertised, use the highest.
+
+=item adv_speed_down
+
+For broadband, the advertised downstream bandwidth in the zone.
+
+=item cir_speed_up
+
+For broadband, the contractually guaranteed upstream bandwidth, if that type
+of service is sold.
+
+=item cir_speed_down
+
+For broadband, the contractually guaranteed downstream bandwidth, if that 
+type of service is sold.
+
+=item is_consumer
+
+'Y' if this service is sold for consumer/household use.
+
+=item is_business
+
+'Y' if this service is sold to business or institutional use.  Not mutually
+exclusive with is_consumer.
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new zone.  To add the zone to the database, see L<"insert">.
+
+=cut
+
+# the new method can be inherited from FS::Record, if a table method is defined
+
+sub table { 'deploy_zone'; }
+
+=item insert ELEMENTS
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+# the insert method can be inherited from FS::Record
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+sub delete {
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  # clean up linked records
+  my $self = shift;
+  my $error = $self->process_o2m(
+    'table'   => $self->element_table,
+    'num_col' => 'zonenum',
+    'fields'  => 'zonenum',
+    'params'  => {},
+  ) || $self->SUPER::delete(@_);
+  
+  if ($error) {
+    dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+  '';
+}
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+# the replace method can be inherited from FS::Record
+
+=item check
+
+Checks all fields to make sure this is a valid zone record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('zonenum')
+    || $self->ut_textn('description')
+    || $self->ut_number('agentnum')
+    || $self->ut_foreign_key('agentnum', 'agent', 'agentnum')
+    || $self->ut_alphan('dbaname')
+    || $self->ut_enum('zonetype', [ 'B', 'P' ])
+    || $self->ut_number('technology')
+    || $self->ut_numbern('spectrum')
+    || $self->ut_enum('servicetype', [ 'broadband', 'voice' ])
+    || $self->ut_decimaln('adv_speed_up', 3)
+    || $self->ut_decimaln('adv_speed_down', 3)
+    || $self->ut_decimaln('cir_speed_up', 3)
+    || $self->ut_decimaln('cir_speed_down', 3)
+    || $self->ut_flag('is_consumer')
+    || $self->ut_flag('is_business')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=item element_table
+
+Returns the name of the table that contains the zone's elements (blocks or
+vertices).
+
+=cut
+
+sub element_table {
+  my $self = shift;
+  if ($self->zonetype eq 'B') {
+    return 'deploy_zone_block';
+  } elsif ( $self->zonetype eq 'P') {
+    return 'deploy_zone_vertex';
+  } else {
+    die 'unknown zonetype';
+  }
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>
+
+=cut
+
+1;
+
diff --git a/FS/FS/deploy_zone_block.pm b/FS/FS/deploy_zone_block.pm
new file mode 100644
index 0000000..58234b9
--- /dev/null
+++ b/FS/FS/deploy_zone_block.pm
@@ -0,0 +1,126 @@
+package FS::deploy_zone_block;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+
+=head1 NAME
+
+FS::deploy_zone_block - Object methods for deploy_zone_block records
+
+=head1 SYNOPSIS
+
+  use FS::deploy_zone_block;
+
+  $record = new FS::deploy_zone_block \%hash;
+  $record = new FS::deploy_zone_block { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::deploy_zone_block object represents a census block that's part of
+a deployment zone.  FS::deploy_zone_block inherits from FS::Record.  The 
+following fields are currently supported:
+
+=over 4
+
+=item blocknum
+
+primary key
+
+=item zonenum
+
+L<FS::deploy_zone> foreign key for the zone.
+
+=item censusblock
+
+U.S. census block number (15 digits).
+
+=item censusyear
+
+The year of the census map where the block appeared or was last verified.
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new block entry.  To add the recordto the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+# the new method can be inherited from FS::Record, if a table method is defined
+
+sub table { 'deploy_zone_block'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+# the insert method can be inherited from FS::Record
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+# the delete method can be inherited from FS::Record
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+# the replace method can be inherited from FS::Record
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('blocknum')
+    || $self->ut_number('zonenum')
+    || $self->ut_number('censusblock')
+    || $self->ut_number('censusyear')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 SEE ALSO
+
+L<FS::Record>
+
+=cut
+
+1;
+
diff --git a/FS/FS/deploy_zone_vertex.pm b/FS/FS/deploy_zone_vertex.pm
new file mode 100644
index 0000000..a25bfde
--- /dev/null
+++ b/FS/FS/deploy_zone_vertex.pm
@@ -0,0 +1,125 @@
+package FS::deploy_zone_vertex;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+
+=head1 NAME
+
+FS::deploy_zone_vertex - Object methods for deploy_zone_vertex records
+
+=head1 SYNOPSIS
+
+  use FS::deploy_zone_vertex;
+
+  $record = new FS::deploy_zone_vertex \%hash;
+  $record = new FS::deploy_zone_vertex { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::deploy_zone_vertex object represents a vertex of a polygonal 
+deployment zone (L<FS::deploy_zone>).  FS::deploy_zone_vertex inherits from
+FS::Record.  The following fields are currently supported:
+
+=over 4
+
+=item vertexnum
+
+primary key
+
+=item zonenum
+
+Foreign key to L<FS::deploy_zone>.
+
+=item latitude
+
+Latitude, as a decimal; positive values are north of the Equator.
+
+=item longitude
+
+Longitude, as a decimal; positive values are east of Greenwich.
+
+=item sequence
+
+The ordinal position of this vertex, starting with zero.
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new vertex record.  To add the record to the database, see L<"insert">.
+
+=cut
+
+sub table { 'deploy_zone_vertex'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid vertex.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+# the check method should currently be supplied - FS::Record contains some
+# data checking routines
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('vertexnum')
+    || $self->ut_number('zonenum')
+    || $self->ut_coord('latitude')
+    || $self->ut_coord('longitude')
+    || $self->ut_number('sequence')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>
+
+=cut
+
+1;
+
diff --git a/FS/FS/part_pkg_fcc_option.pm b/FS/FS/part_pkg_fcc_option.pm
index 0a288de..a090b96 100644
--- a/FS/FS/part_pkg_fcc_option.pm
+++ b/FS/FS/part_pkg_fcc_option.pm
@@ -112,31 +112,31 @@ tie our %media_types, 'Tie::IxHash', (
   'Other'           => [ 90, 0 ],
 );
 
-our %technology_labels = (
-      10 => 'Other ADSL',
-      11 => 'ADSL2',
-      12 => 'VDSL',
-      20 => 'SDSL',
-      30 => 'Other Copper Wireline',
-      40 => 'Other Cable Modem',
-      41 => 'Cable - DOCSIS 1, 1.1, 2.0',
-      42 => 'Cable - DOCSIS 3.0',
-      50 => 'Fiber',
-      60 => 'Satellite',
-      70 => 'Terrestrial Fixed Wireless',
-      # mobile wireless
-      80 => 'Mobile - WCDMA/UMTS/HSPA',
-      81 => 'Mobile - HSPA+',
-      82 => 'Mobile - EVDO/EVDO Rev A',
-      83 => 'Mobile - LTE',
-      84 => 'Mobile - WiMAX',
-      85 => 'Mobile - CDMA',
-      86 => 'Mobile - GSM',
-      87 => 'Mobile - Analog',
-      88 => 'Other Mobile',
-
-      90 => 'Electric Power Line',
-      0  => 'Other'
+tie our %technology_labels, 'Tie::IxHash',  (
+  10 => 'Other ADSL',
+  11 => 'ADSL2',
+  12 => 'VDSL',
+  20 => 'SDSL',
+  30 => 'Other Copper Wireline',
+  40 => 'Other Cable Modem',
+  41 => 'Cable - DOCSIS 1, 1.1, 2.0',
+  42 => 'Cable - DOCSIS 3.0',
+  50 => 'Fiber',
+  60 => 'Satellite',
+  70 => 'Terrestrial Fixed Wireless',
+  # mobile wireless
+  80 => 'Mobile - WCDMA/UMTS/HSPA',
+  81 => 'Mobile - HSPA+',
+  82 => 'Mobile - EVDO/EVDO Rev A',
+  83 => 'Mobile - LTE',
+  84 => 'Mobile - WiMAX',
+  85 => 'Mobile - CDMA',
+  86 => 'Mobile - GSM',
+  87 => 'Mobile - Analog',
+  88 => 'Other Mobile',
+
+  90 => 'Electric Power Line',
+  0  => 'Other'
 );
 
 sub media_types {
@@ -144,7 +144,7 @@ sub media_types {
 }
 
 sub technology_labels {
-  +{ %technology_labels };
+  Storable::dclone(\%technology_labels);
 }
 
 =head1 BUGS
diff --git a/FS/MANIFEST b/FS/MANIFEST
index 693904d..9a9573d 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -812,3 +812,9 @@ FS/state.pm
 t/state.t
 FS/queue_stat.pm
 t/queue_stat.t
+FS/deploy_zone.pm
+t/deploy_zone.t
+FS/deploy_zone_block.pm
+t/deploy_zone_block.t
+FS/deploy_zone_vertex.pm
+t/deploy_zone_vertex.t
diff --git a/FS/t/deploy_zone.t b/FS/t/deploy_zone.t
new file mode 100644
index 0000000..d220e81
--- /dev/null
+++ b/FS/t/deploy_zone.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::deploy_zone;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/deploy_zone_block.t b/FS/t/deploy_zone_block.t
new file mode 100644
index 0000000..c3241b1
--- /dev/null
+++ b/FS/t/deploy_zone_block.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::deploy_zone_block;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/deploy_zone_vertex.t b/FS/t/deploy_zone_vertex.t
new file mode 100644
index 0000000..78c079f
--- /dev/null
+++ b/FS/t/deploy_zone_vertex.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::deploy_zone_vertex;
+$loaded=1;
+print "ok 1\n";
diff --git a/bin/convert-477-options b/bin/convert-477-options
index 2b8970a..99a6ea5 100755
--- a/bin/convert-477-options
+++ b/bin/convert-477-options
@@ -121,7 +121,7 @@ for my $part_pkg (qsearch('part_pkg', { freq => {op => '!=', value => '0'}})) {
     if ($part_pkg->fcc_voip_class) {
       # there's no such thing as a VoIP DS0 equivalent, but this is
       # what we used the field for
-      push @fcc_opts, 'voip_lines' => $part_pkg->fcc_ds0s;
+      push @fcc_opts, 'voip_sessions' => $part_pkg->fcc_ds0s;
     } else {
       push @fcc_opts, 'phone_lines' => $part_pkg->fcc_ds0s, 'is_phone' => 1;
     }
diff --git a/httemplate/browse/deploy_zone.html b/httemplate/browse/deploy_zone.html
new file mode 100644
index 0000000..489a226
--- /dev/null
+++ b/httemplate/browse/deploy_zone.html
@@ -0,0 +1,72 @@
+<& /elements/header.html, 'Deployment zones' &>
+<& /elements/menubar.html,
+  'Add a new fixed broadband zone' => $p.'edit/deploy_zone-fixed.html',
+  'Add a new mobile zone'          => $p.'edit/deploy_zone-mobile.html',
+&>
+<P><FONT SIZE="+1"><B>Fixed Broadband Zones</B></FONT></P>
+<& elements/browse.html,
+  name_singular   => 'zone',
+  query           => { table      => 'deploy_zone',
+                       hashref    => { zonetype => 'B' },
+                     },
+  count_query     => "SELECT COUNT(*) FROM deploy_zone WHERE zonetype = 'B'",
+  agent_virt      => 1,
+  header          => [  '#',
+                        'Description',
+                        'Technology',
+                        'Market',
+                        'Advertised Mbps',
+                        'Contractual Mbps',
+                        'Census blocks',
+                     ],
+  fields          => [  'zonenum',
+                        'description',
+                        sub { my $self = shift;
+                              $tech_label->{$self->technology} },
+                        sub { my $self = shift;
+                              join( ' / ',
+                                $self->is_consumer ? 'consumer' : (),
+                                $self->is_business ? 'business' : ()
+                              )
+                            },
+                        sub { my $self = shift;
+                              join( ' / ', grep $_,
+                                $self->adv_speed_down,
+                                $self->adv_speed_up
+                              )
+                            },
+                        sub { my $self = shift;
+                              join( ' / ', grep $_,
+                                $self->cir_speed_down,
+                                $self->cir_speed_up
+                              )
+                            },
+                        sub { my $self = shift;
+                              FS::deploy_zone_block->count('zonenum = '.$self->zonenum)
+                            },
+                     ],
+  sort_fields     => [ 'zonenum',
+                       'description',
+                       'technology',
+                       'is_consumer is not null, is_business is not null',
+                       'adv_speed_down, adv_speed_up',
+                       'cir_speed_down, cir_speed_up',
+                     ],
+  links           => [  '', $link_fixed, ],
+  align           => 'clllllr',
+  nohtmlheader    => 1,
+  disable_maxselect => 1,
+  disable_total     => 1,
+&>
+<%init>
+my $curuser = $FS::CurrentUser::CurrentUser;
+my $acl_edit = $curuser->access_right('Edit FCC report configuration');
+my $acl_edit_global = $curuser->access_right('Edit FCC report configuration for all agents');
+die "access denied"
+  unless $acl_edit or $acl_edit_global;
+
+my $link_fixed = [ $p.'edit/deploy_zone-fixed.html?', 'zonenum' ];
+my $link_mobile= [ $p.'edit/deploy_zone-mobile.html', 'zonenum' ];
+
+my $tech_label = FS::part_pkg_fcc_option->technology_labels;
+</%init>
diff --git a/httemplate/browse/part_pkg-fcc.html b/httemplate/browse/part_pkg-fcc.html
new file mode 100755
index 0000000..9462c32
--- /dev/null
+++ b/httemplate/browse/part_pkg-fcc.html
@@ -0,0 +1,215 @@
+<& elements/browse.html,
+  'title'                 => 'Package Definitions - FCC Options',
+  'menubar'               => \@menubar,
+  'html_init'             => $html_init,
+  'html_form'             => $html_form,
+  'html_posttotal'        => $html_posttotal,
+  'name'                  => 'package definitions',
+  'disableable'           => 1,
+  'disabled_statuspos'    => 4,
+  'agent_virt'            => 1,
+  'agent_null_right'      => [ $edit, $edit_global ],
+  'agent_null_right_link' => $edit_global,
+  'agent_pos'             => 6,
+  'query'                 =>
+                            { 'select'    => $select,
+                              'table'     => 'part_pkg',
+                              'addl_from' => $addl_from,
+                              'hashref'   => \%hash,
+                              'extra_sql' => $extra_sql,
+                              'order_by'  => "ORDER BY $orderby"
+                            },
+  'count_query'           => $count_query,
+  'header'                => \@header,
+  'fields'                => \@fields,
+  'links'                 => \@links,
+  'align'                 => $align,
+  'link_field'            => 'pkgpart',
+  'html_foot'             => $html_foot,
+&>
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+my $edit        = 'Edit package definitions';
+my $edit_global = 'Edit global package definitions';
+my $acl_edit        = $curuser->access_right($edit);
+my $acl_edit_global = $curuser->access_right($edit_global);
+
+die "access denied"
+  unless $acl_edit || $acl_edit_global;
+
+my $conf = new FS::Conf;
+
+my $orderby = 'pkgpart';
+my %hash = ();
+my $extra_count = '';
+
+my @where = ();
+
+# only ever show recurring packages here
+$hash{'freq'} = { op=>'!=', value=>'0' };
+$extra_count = " freq != '0' ";
+
+# filter by classnum
+my $classnum = '';
+if ( $cgi->param('classnum') =~ /^(\d+)$/ ) {
+  $classnum = $1;
+  push @where, $classnum ? "classnum =  $classnum"
+                         : "classnum IS NULL";
+}
+$cgi->delete('classnum');
+
+# filter by agent permissions
+push @where, FS::part_pkg->curuser_pkgs_sql
+  unless $acl_edit_global;
+
+my $extra_sql = scalar(@where)
+                ? ( scalar(keys %hash) ? ' AND ' : ' WHERE ' ).
+                  join( 'AND ', @where)
+                : '';
+
+# pull option values into the select
+my @optionnames = ( qw(
+  media
+  is_consumer
+  is_broadband technology broadband_upstream broadband_downstream
+  is_phone phone_wholesale phone_vges phone_circuits 
+  phone_lines phone_longdistance phone_localloop
+  is_voip voip_lastmile voip_sessions
+) );
+
+my $select = join(',',
+  'part_pkg.*',
+  '(SELECT classname FROM pkg_class WHERE pkg_class.classnum = part_pkg.classnum) AS classname', # grr, disableable...
+  @optionnames
+);
+
+my $addl_from = 
+  FS::Report::FCC_477::join_optionnames(@optionnames);
+
+#restore this so pagination works
+$cgi->param('classnum', $classnum) if length($classnum);
+
+#should hide this if there aren't any classes
+my $html_posttotal =
+  "<BR>( show class: ".
+  include('/elements/select-pkg_class.html',
+            #'curr_value'    => $classnum,
+            'value'         => $classnum, #insist on 0 :/
+            'onchange'      => 'filter_change()',
+            'pre_options'   => [ '-1' => 'all',
+                                 '0'  => '(none)', ],
+            'disable_empty' => 1,
+         ).
+  ' )';
+
+my $link = [ $p.'edit/part_pkg.cgi?', 'pkgpart' ];
+
+my @header = ( '#', 'Package', 'Comment' );
+my @fields = ( 'pkgpart', 'pkg', 'comment' ,);
+my $align = 'rll';
+my @links = ( $link, $link, '', );
+
+unless ( length($classnum) ) {
+  push @header, 'Class';
+  push @fields, 'classname';
+  $align .= 'l';
+}
+
+# still include the report_option classes, to help with migration
+# but not other plan options
+
+my %report_optionname_name = map { 'report_option_'.$_->num, $_->name }
+  qsearch('part_pkg_report_option', { disabled => '' });
+
+push @header, 'Report classes';
+
+push @fields, 
+              sub {
+                    my $part_pkg = shift;
+                    my %options = $part_pkg->options;
+                    # gather any options that are really report options,
+                    # convert them to their user-friendly names,
+                    # and sort them (I think?)
+                    my @report_options =
+                      sort { $a cmp $b }
+                      map { $report_optionname_name{$_} }
+                      grep { $options{$_}
+                             and exists($report_optionname_name{$_}) }
+                      keys %options;
+
+                    my @rows;
+                    foreach (@report_options) {
+                      push @rows, [
+                        { 'data'  => $_,
+                          'align' => 'center',
+                          'colspan' => 2
+                        }
+                      ];
+                    } # foreach @report_options
+                    \@rows;
+                  };
+
+$align .= 'cr';
+
+# --------
+# now the FCC option part
+# --------
+
+my @pkgparts;
+push @header, 'FCC report parameters';
+push @fields, sub {
+  my $part_pkg = shift;
+  my %hash = $part_pkg->fcc_options;
+  include('/elements/input-fcc_options.html',
+            id          => 'pkgpart'.$part_pkg->pkgpart,
+            curr_value  => encode_json(\%hash),
+            html_only   => 1
+  );
+};
+$align .= 'l';
+
+my $count_extra_sql = $extra_sql;
+$count_extra_sql =~ s/^\s*AND /WHERE /i;
+$extra_count = ( $count_extra_sql ? ' AND ' : ' WHERE ' ). $extra_count
+  if $extra_count;
+my $count_query = "SELECT COUNT(*) FROM part_pkg $count_extra_sql $extra_count";
+
+my $html_init = 
+  include('/elements/init_overlib.html') .
+  include('/elements/input-fcc_options.html', js_only => 1) .
+  include('.style');
+
+my $html_form = '';
+my $html_foot = '';
+# insert a checkbox column
+unshift @header, '';
+unshift @fields, sub {
+  '<INPUT TYPE="checkbox" NAME="pkgpart" VALUE=' . $_[0]->pkgpart .'>';
+};
+unshift @links, '';
+$align = 'c'.$align;
+
+
+$html_form = qq!<FORM ACTION="${p}edit/process/bulk-part_pkg-fcc.html" METHOD="POST">!;
+$html_foot = qq!
+  <INPUT TYPE="submit" VALUE="Save changes">
+  </FORM>!;
+
+my @menubar =
+  ( 'Package definitions' => $p.'browse/part_pkg.cgi' );
+
+</%init>
+<%def .style>
+<style>
+  ul.fcc_options {
+    text-align: left;
+  }
+  ul.fcc_options li {
+  }
+  button.edit_fcc_options {
+    float: right;
+  }
+</style>
+</%def>
diff --git a/httemplate/edit/deploy_zone-fixed.html b/httemplate/edit/deploy_zone-fixed.html
new file mode 100644
index 0000000..ecec9c4
--- /dev/null
+++ b/httemplate/edit/deploy_zone-fixed.html
@@ -0,0 +1,87 @@
+<& elements/edit.html,
+    'name_singular' => 'deployment zone',
+    'table'         => 'deploy_zone',
+    'post_url'      => popurl(1).'process/deploy_zone-fixed.html',
+    'labels'        => {
+        'description'     => 'Description',
+        'agentnum'        => 'Agent',
+        'dbaname'         => 'Business name (if different from agent)',
+        'technology'      => 'Technology',
+        'adv_speed_up'    => 'Upstream',
+        'adv_speed_down'  => 'Downstream',
+        'cir_speed_up'    => 'Upstream',
+        'cir_speed_down'  => 'Downstream',
+        'is_consumer'     => 'Consumer/mass market',
+        'is_business'     => 'Business/government',
+        'blocknum'        => '',
+    },
+    'fields'        => [
+        { field         => 'zonetype',
+          type          => 'hidden',
+          value         => 'B'
+        },
+        { field         => 'servicetype',
+          type          => 'hidden',
+          value         => 'broadband'
+        },
+        'description',
+        { field         => 'agentnum',
+          type          => 'select-agent',
+          disable_empty => 1,
+          viewall_right => 'Edit FCC report configuration for all agents',
+        },
+        'dbaname',
+        { field         => 'technology',
+          type          => 'select',
+          options       => [ keys(%$technology_labels) ],
+          labels        => $technology_labels,
+        },
+        { field         => 'is_consumer', type => 'checkbox', value=>'Y' },
+        { field         => 'is_business', type => 'checkbox', value=>'Y' },
+        { type => 'tablebreak-tr-title',
+          value => 'Advertised maximum speed (Mbps)' },
+        'adv_speed_down',
+        'adv_speed_up',
+        { type => 'tablebreak-tr-title',
+          value => 'Contractually guaranteed speed (Mbps)' },
+        'cir_speed_down',
+        'cir_speed_up',
+
+        { type => 'tablebreak-tr-title', value => 'Census blocks'},
+        { field => 'blocknum',
+          type              => 'deploy_zone_block',
+          o2m_table         => 'deploy_zone_block',
+          m2_label          => ' ',
+          m2_error_callback => $m2_error_callback,
+        },
+    ],
+
+&>
+<%init>
+my $curuser = $FS::CurrentUser::CurrentUser;
+die "access denied"
+  unless $curuser->access_right([
+    'Edit FCC report configuration',
+    'Edit FCC report configuration for all agents',
+  ]);
+
+my $technology_labels = FS::part_pkg_fcc_option->technology_labels;
+
+my $m2_error_callback = sub {
+  my ($cgi, $deploy_zone) = @_;
+  my @blocknums = grep {
+    /^blocknum\d+/ and length($cgi->param($_.'_censusblock'))
+  } $cgi->param;
+
+  map {
+    my $k = $_;
+    FS::deploy_zone_block->new({
+      blocknum    => scalar($cgi->param($k)),
+      zonenum     => $deploy_zone->zonenum,
+      censusblock => scalar($cgi->param($k.'_censusblock')),
+      censusyear  => scalar($cgi->param($k.'_censusyear')),
+    })
+  } @blocknums;
+};
+
+</%init>
diff --git a/httemplate/edit/part_pkg.cgi b/httemplate/edit/part_pkg.cgi
index 9e55d9f..65eca6c 100755
--- a/httemplate/edit/part_pkg.cgi
+++ b/httemplate/edit/part_pkg.cgi
@@ -232,23 +232,26 @@
                        },
                      },
 
-                     { type  => 'tablebreak-tr-title',
-                       value => 'FCC Form 477 information',
-                     },
-                     { field => 'fcc_options_string',
-                       type  => 'input-fcc_options',
-                       curr_value_callback => sub {
-                         my ($cgi, $part_pkg, $fref) = @_;
-                         if ( $cgi->param('fcc_options_string') ) {
-                           # error redirect
-                           return $cgi->param('fcc_options_string');
-                         }
-                         my %hash;
-                         %hash = $part_pkg->fcc_options 
-                           if ($part_pkg->pkgpart);
-                         return encode_json(\%hash);
+                     ($fcc_opts ? (
+                       { type  => 'tablebreak-tr-title',
+                         value => 'FCC Form 477 information',
                        },
-                     },
+                       { field => 'fcc_options_string',
+                         type  => 'input-fcc_options',
+                         curr_value_callback => sub {
+                           my ($cgi, $part_pkg, $fref) = @_;
+                           if ( $cgi->param('fcc_options_string') ) {
+                             # error redirect
+                             return $cgi->param('fcc_options_string');
+                           }
+                           my %hash;
+                           %hash = $part_pkg->fcc_options 
+                             if ($part_pkg->pkgpart);
+                           return encode_json(\%hash);
+                         },
+                       },
+                       ) : ()
+                     ),
 
                      { type  => 'tablebreak-tr-title',
                        value => 'External Links', #better name?
@@ -405,6 +408,8 @@ my $agent_clone_extra_sql =
 my $conf = new FS::Conf;
 my $taxproducts = $conf->exists('enable_taxproducts');
 
+my $fcc_opts = $conf->exists('part_pkg-show_fcc_options');
+
 my @locales = grep { ! /^en_/i } $conf->config('available-locales'); #should filter from the default locale lang instead of en_
 my %locale_labels =  map {
   ( $_ => 'Package -- '. FS::Locales->description($_) )
diff --git a/httemplate/edit/process/deploy_zone-fixed.html b/httemplate/edit/process/deploy_zone-fixed.html
new file mode 100644
index 0000000..c14c81c
--- /dev/null
+++ b/httemplate/edit/process/deploy_zone-fixed.html
@@ -0,0 +1,9 @@
+<& elements/process.html, 
+    error_redirect => popurl(2).'deploy_zone-fixed.html?',
+    table       => 'deploy_zone',
+    viewall_dir => 'browse',
+    process_o2m => 
+      { 'table'  => 'deploy_zone_block',
+                     'fields' => [qw( censusblock censusyear )]
+      },
+&>
diff --git a/httemplate/elements/deploy_zone_block.html b/httemplate/elements/deploy_zone_block.html
new file mode 100644
index 0000000..9985944
--- /dev/null
+++ b/httemplate/elements/deploy_zone_block.html
@@ -0,0 +1,47 @@
+% unless ( $opt{'js_only'} ) {
+
+  <INPUT TYPE="hidden" NAME="<%$name%>" ID="<%$id%>" VALUE="<% $curr_value %>">
+  Block 
+  <INPUT TYPE  = "text"
+         NAME  = "<%$name%>_censusblock"
+         ID    = "<%$id%>_censusblock"
+         VALUE = "<% scalar($cgi->param($name.'_censusblock'))
+                      || $deploy_zone_block->censusblock
+                 %>"
+         SIZE  = 17
+         MAXLENGTH = 15
+         <% $onchange %>
+  >
+   
+  Year
+  <INPUT TYPE  = "text"
+         NAME  = "<%$name%>_censusyear" 
+         ID    = "<%$id%>_censusyear"
+         VALUE = "<% scalar($cgi->param($name.'_censusyear'))
+                      || $deploy_zone_block->censusyear
+                  %>"
+         SIZE  = 5
+         MAXLENGTH = 4
+         <% $onchange %>
+  >
+% }
+<%init>
+
+my( %opt ) = @_;
+
+my $name = $opt{'element_name'} || $opt{'field'} || 'blocknum';
+my $id = $opt{'id'} || 'blocknum';
+
+my $curr_value = $opt{'curr_value'} || $opt{'value'};
+
+my $onchange = $opt{'onchange'};
+if ( $onchange ) {
+  $onchange =~ s/\(what\);/(this);/;
+  $onchange = 'onchange="'.$onchange.'"';
+}
+
+my $deploy_zone_block = $curr_value
+  ? FS::deploy_zone_block->by_key($curr_value)
+  : FS::deploy_zone_block->new;
+
+</%init>
diff --git a/httemplate/elements/tr-input-fcc_options.html b/httemplate/elements/input-fcc_options.html
similarity index 67%
copy from httemplate/elements/tr-input-fcc_options.html
copy to httemplate/elements/input-fcc_options.html
index 11cb4a9..b191e1c 100644
--- a/httemplate/elements/tr-input-fcc_options.html
+++ b/httemplate/elements/input-fcc_options.html
@@ -1,34 +1,32 @@
-<STYLE>
-  ul.fcc_options {
-    font-weight: normal;
-    text-align: left;
-    padding: 0em 1em 0em 2em;
-  }
-</STYLE>
-<TR>
-  <TH COLSPAN=2>
-    <& hidden.html, 'id' => $id, @_ &>
+% unless ($opt{js_only}) {
+<& hidden.html, 'field' => $id, @_ &>
 %#    <& 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>
+<UL ID="<%$id%>_display_fcc_options" CLASS="fcc_options">
+</UL>
+<button type="button" class="edit_fcc_button" data-target="<% $id %>">
+  Edit
+</button>
+% }
+% unless ($opt{html_only}) {
+%   my $popup = $fsurl.'misc/part_pkg_fcc_options.html?id=';
+%   my $popup_name = 'popup-'.time. "-$$-". rand() * 2**32;
 <SCRIPT TYPE="text/javascript">
 function edit_fcc_options() {
-  <& popup_link_onclick.html,
-  'action'      => $fsurl.'misc/part_pkg_fcc_options.html?id=' . $id,
-  'actionlabel' => 'FCC Form 477 options',
-  'width'       => 760,
-  'height'      => 600,
-  &>
+  var id = this.dataset['target'];
+  overlib(
+    OLiframeContent( '<% $popup %>' + id,
+      760, 600, '<% $popup_name %>', 0, 'auto' ),
+    CAPTION, 'FCC Form 477 options', 
+    STICKY, AUTOSTATUSCAP, MIDX, 0, MIDY, 0,
+    DRAGGABLE, CLOSECLICK,
+    BGCOLOR, '#333399', CGCOLOR, '#333399',
+    CLOSETEXT, 'Close'
+  );
 }
+
 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);
+function show_fcc_options(id) {
+  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'];
@@ -82,20 +80,28 @@ function show_fcc_options() {
   } // 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>' + curr_values['voip_sessions'] + 
+           '</strong> sessions allowed</li>';
+    if ( curr_values['voip_lastmile'] ) {
       out += '<li><strong>Including</strong> last-mile connection</li>';
+    } else {
+      out += '<li>Using a <strong>separate</strong> last-mile connection</li>';
     }
   } // is_voip
 
-  var out_ul = document.getElementById('<% $id %>_display_fcc_options');
+  var out_ul = document.getElementById(id + '_display_fcc_options');
   out_ul.innerHTML = out;
 }
 <&| onload.js &>
-  show_fcc_options();
+  var edit_fcc_buttons = document.getElementsByClassName('edit_fcc_button');
+  for(var i = 0; i < edit_fcc_buttons.length; i++) {
+    var button = edit_fcc_buttons[i];
+    show_fcc_options( button.dataset['target'] );
+    button.addEventListener('click', edit_fcc_options);
+  }
 </&>
 </SCRIPT>
+% }
 <%init>
 my %opt = @_;
 my $id = $opt{id} || $opt{field};
diff --git a/httemplate/elements/tr-input-fcc_options.html b/httemplate/elements/tr-input-fcc_options.html
index 11cb4a9..58f7247 100644
--- a/httemplate/elements/tr-input-fcc_options.html
+++ b/httemplate/elements/tr-input-fcc_options.html
@@ -41,9 +41,9 @@ function show_fcc_options() {
   }
   var media = String.toLowerCase(curr_values['media'] || 'unknown media');
   if ( curr_values['is_consumer'] ) {
-    out += '<li><strong>Consumer-grade</strong> service</li>>';
+    out += '<li><strong>Consumer-grade</strong></li>>';
   } else {
-    out += '<li><strong>Business-grade</strong> service</li>';
+    out += '<li><strong>Business-grade</strong></li>';
   }
   if ( curr_values['is_broadband'] ) {
     out += '<li>Broadband via <strong>' + tech + '</strong>'
diff --git a/httemplate/misc/part_pkg_fcc_options.html b/httemplate/misc/part_pkg_fcc_options.html
index f743284..a5ecb12 100644
--- a/httemplate/misc/part_pkg_fcc_options.html
+++ b/httemplate/misc/part_pkg_fcc_options.html
@@ -90,6 +90,8 @@
     <& .checkbox, 'is_voip' &>
     <LABEL FOR="is_voip">This package provides VoIP telephone service</LABEL>
     <FIELDSET ID="voip">
+      <LABEL FOR="voip_sessions">Number of simultaneous calls possible</LABEL>
+      <INPUT NAME="voip_sessions" ID="voip_sessions">
       <& .checkbox, 'voip_lastmile' &>
       <LABEL FOR="voip_lastmile">Do you also provide last-mile connectivity?</LABEL>
     </FIELDSET>
@@ -145,7 +147,7 @@ function save_changes() {
   }
   parent_input.value = JSON.stringify(data);
   // update the display
-  parent.show_fcc_options();
+  parent.show_fcc_options(parent_input.id);
   parent.cClick(); //overlib
 }
 
@@ -179,6 +181,11 @@ function enable_fieldset(fieldset_id) {
       form.elements['phone1'].disabled = (this.value == '');
     }
   );
+  addEventListener(form.elements['is_phone'], 'change', 
+    function() {
+      form.elements['phone_wholesale'].dispatchEvent( new Event('change') );
+    }
+  );
 
   // load data from the parent form and trigger handlers
   for(var i = 0; i < form.elements.length; i++) {
diff --git a/httemplate/search/477.html b/httemplate/search/477.html
index 6849337..26bd9f3 100644
--- a/httemplate/search/477.html
+++ b/httemplate/search/477.html
@@ -36,20 +36,19 @@ a.download {
   float: right;
 }
 </STYLE>
-% foreach my $partnum (@partnums) {
-%   $cgi->param('parts', $partnum);
+% foreach my $partname (@partnames) {
+%   $cgi->param('parts', $partname);
 %   $cgi->param('type', 'csv');
 <table class="fcc477part">
   <caption>
-    <span class="parttitle">Part <% $partnum %></span>
+    <span class="parttitle"><% $parttitle{$partname} %></span>
     <a class="download" href="<% $cgi->self_url %>">Download</a>
   </caption>
-%   my $header = ".header$partnum";
-%   my $data = $parts{$partnum};
+%   my $header = ".header_$partname";
+%   my $data = $parts{$partname};
   <thead>
     <& $header &>
   </thead>
-%   #XXX column headings
 %   foreach my $row (@$data) {
   <tr>
 %     foreach my $item (@$row) {
@@ -58,7 +57,7 @@ a.download {
   </tr>
 %   }
 </table>
-% } # foreach $partnum
+% } # foreach $partname
 <& /elements/footer.html &>
 <%init>
 die "access denied"
@@ -80,10 +79,10 @@ 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(
+my @partnames = grep /^\w+$/, $cgi->param('parts');
+foreach my $partname (@partnames) {
+  my $method = "report_$partname";
+  $parts{$partname} ||= FS::Report::FCC_477->$method(
     date      => $date,
     agentnum  => $agentnum
   );
@@ -93,11 +92,11 @@ $m->cache->set($session, \%parts, '1h');
 my $title = 'FCC Form 477 Data - ' . time2str('%b %o, %Y', $date);
 
 if ( $cgi->param('type') eq 'csv' ) {
-  my $partnum = $partnums[0]; # ignore any beyond the first
-  my $data = $parts{$partnum};
+  my $partname = $partnames[0]; # ignore any beyond the first
+  my $data = $parts{$partname};
   my $csv = Text::CSV_XS->new({ eol => "\r\n" }); # i think
 
-  my $filename = time2str('%Y-%m-%d', $date) . '-part' . $partnum . '.csv';
+  my $filename = time2str('%Y-%m-%d', $date) . '-'. $partname . '.csv';
   http_header('Content-Type' => 'text/csv');
   http_header('Content-Disposition' => qq(attachment;filename="$filename"));
 
@@ -111,7 +110,7 @@ if ( $cgi->param('type') eq 'csv' ) {
 }
 
 </%init>
-<%def .header6>
+<%def .header_fixed_broadband>
   <TR CLASS="head">
     <TD ROWSPAN=2>Census Tract</TD>
     <TD ROWSPAN=2>Technology</TD>
@@ -125,30 +124,18 @@ if ( $cgi->param('type') eq 'csv' ) {
     <TD>Consumer</TD>
   </TR>
 </%def>
-<%def .header7>
+<%def .header_fixed_voice>
   <TR CLASS="head">
-    <TD ROWSPAN=2>State</TD>
-    <TD COLSPAN=2>Speed (Mbps)</TD>
-    <TD COLSPAN=2>Subscriptions</TD>
+    <TD ROWSPAN=2>Census Tract</TD>
+    <TD ROWSPAN=2>VoIP?</TD>
+    <TD COLSPAN=2>Lines/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>
+<%def .header_local_phone>
   <TR CLASS="head">
     <TD ROWSPAN=3>State</TD>
     <TD COLSPAN=2>Wholesale</TD>
@@ -183,7 +170,7 @@ if ( $cgi->param('type') eq 'csv' ) {
     <TD>Wireless</TD>
   </TR>
 </%def>
-<%def .header10>
+<%def .header_voip>
   <TR CLASS="head">
     <TD ROWSPAN=2>State</TD>
     <TD COLSPAN=2>VoIP OTT</TD>
@@ -206,14 +193,29 @@ if ( $cgi->param('type') eq 'csv' ) {
     <TD>Other</TD>
   </TR>
 </%def>
-<%def .header11>
+<%def .header_mobile_broadband>
+%# unimplemented
   <TR CLASS="head">
-    <TD ROWSPAN=2>Census Tract</TD>
-    <TD ROWSPAN=2>VoIP?</TD>
-    <TD COLSPAN=2>Lines/Subscriptions</TD>
+    <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 .header_mobile_voice>
+%# unimplemented
+  <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>
+
diff --git a/httemplate/search/report_477.html b/httemplate/search/report_477.html
index e3ae69e..2a6878e 100755
--- a/httemplate/search/report_477.html
+++ b/httemplate/search/report_477.html
@@ -31,12 +31,12 @@
       'label'   => 'Enable parts',
       'field'   => 'parts',
       'labels'  => {
-        6   => 'Part 6 (Fixed Broadband Subscription)',
+        fixed_broadband => '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)',
+        fixed_voice     => 'Voice Telephone Subscription',
+        local_phone     => 'Local Exchange Telephone Subscription',
+        voip            => 'Interconnected VoIP Subscription',
       },
       options => [ 6, 9, 10, 11 ],
     &>

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

Summary of changes:
 FS/FS/AccessRight.pm                               |    3 +
 FS/FS/Mason.pm                                     |    3 +
 FS/FS/Record.pm                                    |   31 +++
 FS/FS/Report/FCC_477.pm                            |  289 ++++++++++++++------
 FS/FS/Schema.pm                                    |   71 +++++
 FS/FS/deploy_zone.pm                               |  277 +++++++++++++++++++
 ...srvderive_component.pm => deploy_zone_block.pm} |   50 ++--
 .../FS/deploy_zone_vertex.pm                       |   54 ++--
 FS/FS/part_pkg.pm                                  |    8 +-
 FS/FS/part_pkg_fcc_option.pm                       |   73 +++--
 FS/MANIFEST                                        |    6 +
 FS/t/{AccessRight.t => deploy_zone.t}              |    2 +-
 FS/t/{AccessRight.t => deploy_zone_block.t}        |    2 +-
 FS/t/{AccessRight.t => deploy_zone_vertex.t}       |    2 +-
 bin/convert-477-options                            |    2 +-
 httemplate/browse/deploy_zone.html                 |  126 +++++++++
 httemplate/browse/part_pkg-fcc.html                |  226 +++++++++++++++
 httemplate/edit/deploy_zone-fixed.html             |   95 +++++++
 httemplate/edit/deploy_zone-mobile.html            |   90 ++++++
 httemplate/edit/part_pkg.cgi                       |   37 +--
 httemplate/edit/process/bulk-part_pkg-fcc.html     |   42 +++
 httemplate/edit/process/deploy_zone-fixed.html     |    9 +
 httemplate/edit/process/deploy_zone-mobile.html    |    9 +
 httemplate/elements/deploy_zone_block.html         |   47 ++++
 httemplate/elements/deploy_zone_vertex.html        |   45 +++
 ...put-fcc_options.html => input-fcc_options.html} |   70 ++---
 httemplate/elements/tr-input-fcc_options.html      |    4 +-
 httemplate/misc/part_pkg_fcc_options.html          |   20 +-
 httemplate/search/477.html                         |   84 +++---
 httemplate/search/report_477.html                  |   26 +-
 30 files changed, 1535 insertions(+), 268 deletions(-)
 create mode 100644 FS/FS/deploy_zone.pm
 copy FS/FS/{torrus_srvderive_component.pm => deploy_zone_block.pm} (58%)
 copy eg/table_template.pm => FS/FS/deploy_zone_vertex.pm (53%)
 copy FS/t/{AccessRight.t => deploy_zone.t} (82%)
 copy FS/t/{AccessRight.t => deploy_zone_block.t} (78%)
 copy FS/t/{AccessRight.t => deploy_zone_vertex.t} (78%)
 create mode 100644 httemplate/browse/deploy_zone.html
 create mode 100755 httemplate/browse/part_pkg-fcc.html
 create mode 100644 httemplate/edit/deploy_zone-fixed.html
 create mode 100644 httemplate/edit/deploy_zone-mobile.html
 create mode 100644 httemplate/edit/process/bulk-part_pkg-fcc.html
 create mode 100644 httemplate/edit/process/deploy_zone-fixed.html
 create mode 100644 httemplate/edit/process/deploy_zone-mobile.html
 create mode 100644 httemplate/elements/deploy_zone_block.html
 create mode 100644 httemplate/elements/deploy_zone_vertex.html
 copy httemplate/elements/{tr-input-fcc_options.html => input-fcc_options.html} (66%)




More information about the freeside-commits mailing list