From 8fdc0ea36474cfb3d1389f41691c14598559cbe7 Mon Sep 17 00:00:00 2001 From: Mark Wells Date: Mon, 21 Jul 2014 15:35:33 -0700 Subject: [PATCH] 477 report rewrite, #28020 --- FS/FS/Conf.pm | 21 +- FS/FS/Mason.pm | 2 + FS/FS/Report/FCC_477.pm | 221 ++++++++++++-- FS/FS/Schema.pm | 14 + FS/FS/Upgrade.pm | 19 ++ FS/FS/part_pkg.pm | 3 +- FS/FS/state.pm | 133 +++++++++ FS/MANIFEST | 4 + FS/MYMETA.json | 39 +++ FS/t/state.t | 5 + bin/convert-477-options | 32 ++- httemplate/elements/tr-input-fcc_options.html | 89 +++++- httemplate/misc/part_pkg_fcc_options.html | 15 +- httemplate/search/477.html | 318 +++++++++++++-------- httemplate/search/old477/477.html | 135 +++++++++ httemplate/search/{ => old477}/477partIA.html | 0 httemplate/search/{ => old477}/477partIIA.html | 0 httemplate/search/{ => old477}/477partIIB.html | 0 httemplate/search/{ => old477}/477partIV.html | 0 httemplate/search/{ => old477}/477partV.html | 2 +- .../search/{ => old477}/477partVI_census.html | 2 +- httemplate/search/old477/report_477.html | 282 ++++++++++++++++++ httemplate/search/report_477.html | 279 ++---------------- 23 files changed, 1183 insertions(+), 432 deletions(-) create mode 100644 FS/FS/state.pm create mode 100644 FS/MYMETA.json create mode 100644 FS/t/state.t mode change 100755 => 100644 httemplate/search/477.html create mode 100755 httemplate/search/old477/477.html rename httemplate/search/{ => old477}/477partIA.html (100%) rename httemplate/search/{ => old477}/477partIIA.html (100%) rename httemplate/search/{ => old477}/477partIIB.html (100%) rename httemplate/search/{ => old477}/477partIV.html (100%) rename httemplate/search/{ => old477}/477partV.html (98%) rename httemplate/search/{ => old477}/477partVI_census.html (99%) create mode 100755 httemplate/search/old477/report_477.html diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index 41b6d24ca..ea007487b 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -3465,13 +3465,6 @@ and customer address. Include units.', }, { - 'key' => 'cust_pkg-show_fcc_voice_grade_equivalent', - 'section' => 'UI', - 'description' => "Show fields on package definitions for FCC Form 477 classification", - 'type' => 'checkbox', - }, - - { 'key' => 'cust_pkg-large_pkg_size', 'section' => 'UI', 'description' => "In customer view, summarize packages with more than this many services. Set to zero to never summarize packages.", @@ -3486,6 +3479,13 @@ and customer address. Include units.', }, { + 'key' => 'part_pkg-show_fcc_options', + 'section' => 'UI', + 'description' => "Show fields on package definitions for FCC Form 477 classification", + 'type' => 'checkbox', + }, + + { 'key' => 'svc_acct-edit_uid', 'section' => 'shell', 'description' => 'Allow UID editing.', @@ -5773,6 +5773,13 @@ and customer address. Include units.', ], }, + { + 'key' => 'old_fcc_report', + 'section' => '', + 'description' => 'Use the old (pre-2014) FCC Form 477 report format.', + 'type' => 'checkbox', + }, + { key => "apacheroot", section => "deprecated", description => "DEPRECATED", type => "text" }, { key => "apachemachine", section => "deprecated", description => "DEPRECATED", type => "text" }, { key => "apachemachines", section => "deprecated", description => "DEPRECATED", type => "text" }, diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm index b7aa35543..1ae60edef 100644 --- a/FS/FS/Mason.pm +++ b/FS/FS/Mason.pm @@ -383,6 +383,8 @@ if ( -e $addl_handler_use_file ) { use FS::export_batch; use FS::export_batch_item; use FS::part_pkg_fcc_option; + use FS::state; + use FS::state; # Sammath Naur if ( $FS::Mason::addl_handler_use ) { diff --git a/FS/FS/Report/FCC_477.pm b/FS/FS/Report/FCC_477.pm index 9c038420b..79f00e371 100644 --- a/FS/FS/Report/FCC_477.pm +++ b/FS/FS/Report/FCC_477.pm @@ -81,6 +81,7 @@ Documentation. ); #from the select at http://www.ffiec.gov/census/default.aspx +#though this is now in the database, also %states = ( '01' => 'ALABAMA (AL)', '02' => 'ALASKA (AK)', @@ -204,10 +205,22 @@ sub statenum2state { $states{$num}; } +=head1 THE "NEW" REPORT (October 2014 and later) + +=head2 METHODS + +=over 4 + +=cut + sub join_optionnames { join(' ', map { join_optionname($_) } @_); } +sub join_optionnames_int { + join(' ', map { join_optionname_int($_) } @_); +} + sub join_optionname { # Returns a FROM phrase to join a specific option into the query (via # part_pkg). The option value will appear as a field with the same name @@ -218,6 +231,17 @@ sub join_optionname { " ON (part_pkg.pkgpart = t_$name.pkgpart)"; } +sub join_optionname_int { + # Returns a FROM phrase to join a specific option into the query (via + # part_pkg) and cast it to integer.. Note this does not convert nulls + # to zero. + my $name = shift; + "LEFT JOIN (SELECT pkgpart, CAST(optionvalue AS int) AS $name + FROM part_pkg_fcc_option". + " WHERE fccoptionname = '$name') AS t_$name". + " ON (part_pkg.pkgpart = t_$name.pkgpart)"; +} + sub active_on { # Returns a condition to limit packages to those that were setup before a # certain date, and not canceled before that date. @@ -230,7 +254,7 @@ sub active_on { } sub is_fixed_broadband { - "is_broadband = '1' AND technology::integer IN(".join(',', + "is_broadband::int = 1 AND technology::int IN(".join(',', 10, 11, 12, 20, 30, 40, 41, 42, 50, 60, 70, 90, 0 ).")"; } @@ -238,8 +262,19 @@ sub is_fixed_broadband { =item part6 OPTIONS Returns Part 6 of the 2014 FCC 477 data, as an arrayref of arrayrefs. -OPTIONS may contain "date" => a timestamp to run the report as of that -date. +OPTIONS may contain: +- date: a timestamp value to count active packages as of that date +- agentnum: limit to customers of that agent + +Part 6 is the broadband subscription detail report. Columns of the +report are: +- census tract +- technology code +- downstream speed +- upstream speed +(the above columns form a key) +- number of subscriptions +- number of consumer-grade subscriptions =cut @@ -247,6 +282,7 @@ sub part6 { my $class = shift; my %opt = shift; my $date = $opt{date} || time; + my $agentnum = $opt{agentnum}; my @select = ( 'cust_location.censustract', @@ -258,18 +294,20 @@ sub part6 { ); my $from = 'cust_pkg - JOIN cust_location USING (locationnum) + JOIN cust_location ON (cust_pkg.locationnum = cust_location.locationnum) + JOIN cust_main ON (cust_pkg.custnum = cust_main.custnum) JOIN part_pkg USING (pkgpart) '. - join_optionnames(qw( + join_optionnames_int(qw( is_broadband technology - broadband_downstream broadband_upstream is_consumer - )) + )). + join_optionnames(qw(broadband_downstream broadband_upstream)) ; my @where = ( active_on($date), is_fixed_broadband() ); + push @where, "cust_main.agentnum = $agentnum" if $agentnum; my $group_by = 'cust_location.censustract, technology, '. 'broadband_downstream, broadband_upstream '; my $order_by = $group_by; @@ -287,7 +325,24 @@ sub part6 { =item part9 OPTIONS -Returns Part 9 of the 2014 FCC 477 data, as above. +Returns Part 9 of the 2014 FCC 477 data. Part 9 is the Local Exchange +Telephone Subscription report. Columns are: + +- state FIPS code (key) +- wholesale switched voice lines +- wholesale unswitched local loops +- end-user total lines +- end-user lines sold in a package with broadband +- consumer-grade lines where you are not the long-distance carrier +- consumer-grade lines where the carrier IS the long-distance carrier +- business-grade lines where you are not the long-distance carrier +- business-grade lines where the carrier IS the long-distance carrier +- end-user lines where you own the local loop facility +- end-user lines where you lease an unswitched local loop from a LEC +- end-user lines resold from another carrier +- end-user lines provided over fiber to the premises +- end-user lines provided over coaxial +- end-user lines provided over fixed wireless =cut @@ -295,39 +350,44 @@ sub part9 { my $class = shift; my %opt = shift; my $date = $opt{date} || time; + my $agentnum = $opt{agentnum}; my @select = ( - "cust_location.state", - "SUM(COALESCE(phone_vges::int,0))", - "SUM(COALESCE(phone_circuits::int,0))", - "SUM(COALESCE(phone_lines::int,0))", - "SUM(CASE WHEN is_broadband = '1' THEN phone_lines::int ELSE 0 END)", - "SUM(CASE WHEN is_consumer = '1' AND is_longdistance IS NULL THEN phone_lines::int ELSE 0 END)", - "SUM(CASE WHEN is_consumer = '1' AND is_longdistance = '1' THEN phone_lines::int ELSE 0 END)", - "SUM(CASE WHEN is_consumer IS NULL AND is_longdistance IS NULL THEN phone_lines::int ELSE 0 END)", - "SUM(CASE WHEN is_consumer IS NULL AND is_longdistance = '1' THEN phone_lines::int ELSE 0 END)", - "SUM(CASE WHEN phone_localloop = 'owned' THEN phone_lines::int ELSE 0 END)", - "SUM(CASE WHEN phone_localloop = 'leased' THEN phone_lines::int ELSE 0 END)", - "SUM(CASE WHEN phone_localloop = 'resale' THEN phone_lines::int ELSE 0 END)", - "SUM(CASE WHEN media = 'Fiber' THEN phone_lines::int ELSE 0 END)", - "SUM(CASE WHEN media = 'Cable Modem' THEN phone_lines::int ELSE 0 END)", - "SUM(CASE WHEN media = 'Fixed Wireless' THEN phone_lines::int ELSE 0 END)", + "state.fips", + "SUM(phone_vges)", + "SUM(phone_circuits)", + "SUM(phone_lines)", + "SUM(CASE WHEN is_broadband = 1 THEN phone_lines ELSE 0 END)", + "SUM(CASE WHEN is_consumer = 1 AND is_longdistance IS NULL THEN phone_lines ELSE 0 END)", + "SUM(CASE WHEN is_consumer = 1 AND is_longdistance = 1 THEN phone_lines ELSE 0 END)", + "SUM(CASE WHEN is_consumer IS NULL AND is_longdistance IS NULL THEN phone_lines ELSE 0 END)", + "SUM(CASE WHEN is_consumer IS NULL AND is_longdistance = 1 THEN phone_lines ELSE 0 END)", + "SUM(CASE WHEN phone_localloop = 'owned' THEN phone_lines ELSE 0 END)", + "SUM(CASE WHEN phone_localloop = 'leased' THEN phone_lines ELSE 0 END)", + "SUM(CASE WHEN phone_localloop = 'resale' THEN phone_lines ELSE 0 END)", + "SUM(CASE WHEN media = 'Fiber' THEN phone_lines ELSE 0 END)", + "SUM(CASE WHEN media = 'Cable Modem' THEN phone_lines ELSE 0 END)", + "SUM(CASE WHEN media = 'Fixed Wireless' THEN phone_lines ELSE 0 END)", ); my $from = 'cust_pkg - JOIN cust_location USING (locationnum) + JOIN cust_location ON (cust_pkg.locationnum = cust_location.locationnum) + JOIN state USING (country, state) + JOIN cust_main ON (cust_pkg.custnum = cust_main.custnum) JOIN part_pkg USING (pkgpart) '. - join_optionnames(qw( - is_phone is_broadband media + join_optionnames_int(qw( + is_phone is_broadband phone_vges phone_circuits phone_lines - is_consumer is_longdistance phone_localloop - )) + is_consumer is_longdistance + )). + join_optionnames('media', 'phone_localloop') ; my @where = ( active_on($date), - "is_phone::int = 1", + "is_phone = 1", ); - my $group_by = 'cust_location.state'; + push @where, "cust_main.agentnum = $agentnum" if $agentnum; + my $group_by = 'state.fips'; my $order_by = $group_by; my $statement = "SELECT ".join(', ', @select) . " @@ -341,5 +401,106 @@ sub part9 { dbh->selectall_arrayref($statement); } +sub part10 { + my $class = shift; + my %opt = shift; + my $date = $opt{date} || time; + my $agentnum = $opt{agentnum}; + + my @select = ( + "state.fips", + # OTT, OTT + consumer + "SUM(CASE WHEN (voip_lastmile IS NULL) THEN 1 ELSE 0 END)", + "SUM(CASE WHEN (voip_lastmile IS NULL AND is_consumer = 1) THEN 1 ELSE 0 END)", + # non-OTT: total, consumer, broadband bundle, media types + "SUM(CASE WHEN (voip_lastmile = 1) THEN 1 ELSE 0 END)", + "SUM(CASE WHEN (voip_lastmile = 1 AND is_consumer = 1) THEN 1 ELSE 0 END)", + "SUM(CASE WHEN (voip_lastmile = 1 AND is_broadband = 1) THEN 1 ELSE 0 END)", + "SUM(CASE WHEN (voip_lastmile = 1 AND media = 'Copper') THEN 1 ELSE 0 END)", + "SUM(CASE WHEN (voip_lastmile = 1 AND media = 'Cable Modem') THEN 1 ELSE 0 END)", + "SUM(CASE WHEN (voip_lastmile = 1 AND media = 'Fiber') THEN 1 ELSE 0 END)", + "SUM(CASE WHEN (voip_lastmile = 1 AND media = 'Fixed Wireless') THEN 1 ELSE 0 END)", + "SUM(CASE WHEN (voip_lastmile = 1 AND media NOT IN('Copper', 'Fiber', 'Cable Modem', 'Fixed Wireless') ) THEN 1 ELSE 0 END)", + ); + + my $from = + 'cust_pkg + JOIN cust_location ON (cust_pkg.locationnum = cust_location.locationnum) + JOIN state USING (country, state) + JOIN cust_main ON (cust_pkg.custnum = cust_main.custnum) + JOIN part_pkg USING (pkgpart) '. + join_optionnames_int( + qw( is_voip is_broadband is_consumer voip_lastmile) + ). + join_optionnames('media') + ; + my @where = ( + active_on($date), + "is_voip = 1", + ); + push @where, "cust_main.agentnum = $agentnum" if $agentnum; + my $group_by = 'state.fips'; + my $order_by = $group_by; + + my $statement = "SELECT ".join(', ', @select) . " + FROM $from + WHERE ".join(' AND ', @where)." + GROUP BY $group_by + ORDER BY $order_by + "; + + warn $statement if $DEBUG; + dbh->selectall_arrayref($statement); +} + +=item part11 OPTIONS + +Returns part 11 (voice subscription detail), as above. + +=cut + +sub part11 { + my $class = shift; + my %opt = shift; + my $date = $opt{date} || time; + my $agentnum = $opt{agentnum}; + + my @select = ( + 'cust_location.censustract', + # VoIP indicator (0 for non-VoIP, 1 for VoIP) + 'COALESCE(is_voip, 0)', + # number of lines/subscriptions + 'SUM(CASE WHEN is_voip = 1 THEN 1 ELSE phone_lines END)', + # consumer grade lines/subscriptions + 'SUM(CASE WHEN is_consumer = 1 THEN ( CASE WHEN is_voip = 1 THEN 1 ELSE phone_lines END) ELSE 0 END)' + ); + + my $from = 'cust_pkg + JOIN cust_location ON (cust_pkg.locationnum = cust_location.locationnum) + JOIN cust_main ON (cust_pkg.custnum = cust_main.custnum) + JOIN part_pkg USING (pkgpart) '. + join_optionnames_int(qw( + is_phone is_voip is_consumer phone_lines + )) + ; + + my @where = ( + active_on($date), + "(is_voip = 1 OR is_phone = 1)", + ); + push @where, "cust_main.agentnum = $agentnum" if $agentnum; + my $group_by = 'cust_location.censustract, COALESCE(is_voip, 0)'; + my $order_by = $group_by; + + my $statement = "SELECT ".join(', ', @select) . " + FROM $from + WHERE ".join(' AND ', @where)." + GROUP BY $group_by + ORDER BY $order_by + "; + + warn $statement if $DEBUG; + dbh->selectall_arrayref($statement); +} 1; diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index 1c9c4a200..40248ddfc 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -6643,6 +6643,20 @@ sub tables_hashref { ], }, + # lookup table for states, similar to msa and lata + 'state' => { + 'columns' => [ + 'statenum', 'int', '', '', '', '', + 'country', 'char', '', 2, '', '', + 'state', 'char', '', $char_d, '', '', + 'fips', 'char', '', 3, '', '', + ], + 'primary_key' => 'statenum', + 'unique' => [ [ 'country', 'state' ], ], + 'index' => [], + }, + + # name type nullability length default local #'new_table' => { diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm index 6785a1375..ce0e328fd 100644 --- a/FS/FS/Upgrade.pm +++ b/FS/FS/Upgrade.pm @@ -97,6 +97,22 @@ sub upgrade_config { $conf->touch('cust_main-enable_spouse'); $conf->delete('cust_main-enable_spouse_birthdate'); } + + # renamed/repurposed + if ( $conf->exists('cust_pkg-show_fcc_voice_grade_equivalent') ) { + $conf->touch('part_pkg-show_fcc_options'); + $conf->delete('cust_pkg-show_fcc_voice_grade_equivalent'); + warn " +You have FCC Form 477 package options enabled. + +Starting with the October 2014 filing date, the FCC has redesigned +Form 477 and introduced new service categories. See bin/convert-477-options +to update your package configuration for the new report. + +If you need to continue using the old Form 477 report, turn on the +'old_fcc_report' configuration option. +"; + } } sub upgrade_overlimit_groups { @@ -343,6 +359,9 @@ sub upgrade_data { #fix taxable line item links 'cust_bill_pkg_tax_location' => [], + + #populate state FIPS codes if not already done + 'state' => [], ; \%hash; diff --git a/FS/FS/part_pkg.pm b/FS/FS/part_pkg.pm index 2ad785939..741eb8741 100644 --- a/FS/FS/part_pkg.pm +++ b/FS/FS/part_pkg.pm @@ -790,7 +790,7 @@ sub propagate { =item process_fcc_options HASHREF Sets the FCC options on this package definition to the values specified -in HASHREF. Names are as in L. +in HASHREF. =cut @@ -807,6 +807,7 @@ sub process_fcc_options { my %existing_num = map { $_->fccoptionname => $_->num } qsearch('part_pkg_fcc_option', { pkgpart => $pkgpart }); + local $FS::Record::nowarn_identical = 1; # set up params for process_o2m my $i = 0; my $params = {}; diff --git a/FS/FS/state.pm b/FS/FS/state.pm new file mode 100644 index 000000000..671a93b44 --- /dev/null +++ b/FS/FS/state.pm @@ -0,0 +1,133 @@ +package FS::state; + +use strict; +use base qw( FS::Record ); +use FS::Record qw( qsearch qsearchs ); +use Locale::SubCountry; + +=head1 NAME + +FS::state - Object methods for state/province records + +=head1 SYNOPSIS + + use FS::state; + + $record = new FS::state \%hash; + $record = new FS::state { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::state object represents a state, province, or other top-level +subdivision of a sovereign nation. FS::state inherits from FS::Record. +The following fields are currently supported: + +=over 4 + +=item statenum + +primary key + +=item country + +two-letter country code + +=item state + +state code/abbreviation/name (as used in cust_location.state) + +=item fips + +FIPS 10-4 code (not including country code) + +=back + +=head1 METHODS + +=cut + +sub table { 'state'; } + +# no external API; this table maintains itself + +sub check { + my $self = shift; + + my $error = + $self->ut_numbern('statenum') + || $self->ut_alpha('country') + || $self->ut_alpha('state') + || $self->ut_alpha('fips') + ; + return $error if $error; + + $self->SUPER::check; +} + +=back + +=cut + +sub _upgrade_data { + warn "Updating state and country codes...\n"; + my %existing; + foreach my $state (qsearch('state')) { + $existing{$state->country} ||= {}; + $existing{$state->country}{$state->state} = $state; + } + my $world = Locale::SubCountry::World->new; + foreach my $country_code ($world->all_codes) { + my $country = Locale::SubCountry->new($country_code); + next unless $country->has_sub_countries; + $existing{$country} ||= {}; + foreach my $state_code ($country->all_codes) { + my $fips = $country->FIPS10_4_code($state_code); + # we really only need U.S. state codes at this point, so if there's + # no FIPS code, ignore it. + next if !$fips or $fips eq 'unknown' or $fips =~ /\W/; + my $this_state = $existing{$country_code}{$state_code}; + if ($this_state) { + if ($this_state->fips ne $fips) { # this should never happen... + $this_state->set(fips => $fips); + my $error = $this_state->replace; + die "error updating $country_code/$state_code:\n$error\n" if $error; + } + delete $existing{$country_code}{$state_code}; + } else { + $this_state = FS::state->new({ + country => $country_code, + state => $state_code, + fips => $fips, + }); + my $error = $this_state->insert; + die "error inserting $country_code/$state_code:\n$error\n" if $error; + } + } + # clean up states that no longer exist (does this ever happen?) + foreach my $state (values %{ $existing{$country_code} }) { + my $error = $state->delete; + die "error removing expired state ".$state->country.'/'.$state->state. + "\n$error\n" if $error; + } + } # foreach $country_code + ''; +} + +=head1 BUGS + +=head1 SEE ALSO + +L + +=cut + +1; + diff --git a/FS/MANIFEST b/FS/MANIFEST index ed8fd9b24..ef9fb4492 100644 --- a/FS/MANIFEST +++ b/FS/MANIFEST @@ -806,3 +806,7 @@ FS/export_batch_item.pm t/export_batch_item.t FS/part_pkg_fcc_option.pm t/part_pkg_fcc_option.t +FS/state.pm +t/state.t +FS/state.pm +t/state.t diff --git a/FS/MYMETA.json b/FS/MYMETA.json new file mode 100644 index 000000000..42001f140 --- /dev/null +++ b/FS/MYMETA.json @@ -0,0 +1,39 @@ +{ + "abstract" : "unknown", + "author" : [ + "unknown" + ], + "dynamic_config" : 0, + "generated_by" : "ExtUtils::MakeMaker version 6.8, CPAN::Meta::Converter version 2.120351", + "license" : [ + "unknown" + ], + "meta-spec" : { + "url" : "http://search.cpan.org/perldoc?CPAN::Meta::Spec", + "version" : "2" + }, + "name" : "FS", + "no_index" : { + "directory" : [ + "t", + "inc" + ] + }, + "prereqs" : { + "build" : { + "requires" : { + "ExtUtils::MakeMaker" : "0" + } + }, + "configure" : { + "requires" : { + "ExtUtils::MakeMaker" : "0" + } + }, + "runtime" : { + "requires" : {} + } + }, + "release_status" : "stable", + "version" : "4.0git" +} diff --git a/FS/t/state.t b/FS/t/state.t new file mode 100644 index 000000000..a21137fd5 --- /dev/null +++ b/FS/t/state.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::state; +$loaded=1; +print "ok 1\n"; diff --git a/bin/convert-477-options b/bin/convert-477-options index a52c56cc1..48a526421 100755 --- a/bin/convert-477-options +++ b/bin/convert-477-options @@ -6,11 +6,11 @@ use FS::Record qw(qsearch qsearchs dbh); use FS::part_pkg_report_option; use Text::CSV; -adminsuidsetup($user) or die "invalid user '$user'"; -$FS::UID::AutoCommit = 1; -$FS::Record::nowarn_classload = 1; +if (!$user) { + print " +Usage: bin/convert-477-options -print "This script will convert your per-package FCC 477 report options +This script will convert your per-package FCC 477 report options from the classic style (part IA, IB, IIA...) to the 2014 style. This is an approximate conversion, and you should review the resulting package settings for accuracy. In particular: @@ -19,11 +19,17 @@ resulting package settings for accuracy. In particular: - Broadband technologies for ADSL and cable modem will be set to 'other ADSL' and 'other cable modem'. You should set them to the specific ADSL or cable encapsulation in use. - - The 'consumer grade' vs. 'business grade' classification - was introduced in 2014 and will not be set. - -Configuring packages... + - All packages will be set to 'business grade'. The 'consumer grade' + category did not exist in previous versions of the report. "; + exit(1); +} + +adminsuidsetup($user) or die "invalid user '$user'"; +$FS::UID::AutoCommit = 1; +$FS::Record::nowarn_classload = 1; + +print "Configuring packages...\n"; my @min_download_speed = ( 0.2, 0.768, 1.5, 3, 6, 10, 25, 100 ); my @min_upload_speed = ( 0.1, @min_download_speed ); @@ -74,7 +80,9 @@ sub report_option_to_fcc_option { technology => $technology[$1]); } elsif ($formkey =~ /^part2a_row_option_(\d+)/) { #local phone options - return (split(':', $phone_option[$1])); + return (media => 'Copper', # sensible default + split(':', $phone_option[$1]) + ); } elsif ($formkey =~ /^part2b_row_option_(\d+)/) { #VoIP options (are all media types) return (split(':', $voip_option[$1])); @@ -86,7 +94,7 @@ sub report_option_to_fcc_option { for my $part_pkg (qsearch('part_pkg', { freq => {op => '!=', value => '0'}})) { my $pkgpart = $part_pkg->pkgpart; - print "#$pkgpart\n"; + #print "#$pkgpart\n"; my %report_opts = $part_pkg->options; my @fcc_opts; foreach my $optionname (keys(%report_opts)) { @@ -113,12 +121,12 @@ for my $part_pkg (qsearch('part_pkg', { freq => {op => '!=', value => '0'}})) { } my %fcc_opts = @fcc_opts; - print map {"\t$_\t".$fcc_opts{$_}."\n"} keys %fcc_opts; + #print map {"\t$_\t".$fcc_opts{$_}."\n"} keys %fcc_opts; my $error = $part_pkg->process_fcc_options(\%fcc_opts); if ( $error ) { die "$error\n"; } - print "\n"; + #print "\n"; } print "Finished.\n"; diff --git a/httemplate/elements/tr-input-fcc_options.html b/httemplate/elements/tr-input-fcc_options.html index bd5083075..11cb4a962 100644 --- a/httemplate/elements/tr-input-fcc_options.html +++ b/httemplate/elements/tr-input-fcc_options.html @@ -1,16 +1,24 @@ + <& hidden.html, 'id' => $id, @_ &> -%# <& input-text.html, 'id' => $id, @_ &> -%# XXX debugging - +%# <& input-text.html, 'id' => $id, @_ &> # XXX debugging +
    +
+ % # show some kind of useful summary of the FCC options here <%init> my %opt = @_; diff --git a/httemplate/misc/part_pkg_fcc_options.html b/httemplate/misc/part_pkg_fcc_options.html index 1f5d4a8bf..f74328446 100644 --- a/httemplate/misc/part_pkg_fcc_options.html +++ b/httemplate/misc/part_pkg_fcc_options.html @@ -90,8 +90,8 @@ <& .checkbox, 'is_voip' &>
- - <& .checkbox, 'voip_ott' &> + <& .checkbox, 'voip_lastmile' &> +

@@ -103,8 +103,8 @@ // this form is invoked as a popup; the current values of the parent // object are in the form field ID passed as the 'id' param -var parent_id = window.parent.document.getElementById('<% $parent_id %>'); -var curr_values = JSON.parse(window.parent_id.value); +var parent_input = window.parent.document.getElementById('<% $parent_id %>'); +var curr_values = JSON.parse(window.parent_input.value); var form = document.forms['fcc_option_form']; var media_types = <% encode_json($media_types) %> var technology_labels = <% encode_json($technology_labels) %> @@ -129,6 +129,9 @@ function save_changes() { var form = document.forms['fcc_option_form']; var data = {}; for (var i = 0; i < form.elements.length; i++) { + if (form.elements[i].type == 'submit') + continue; + // quick and dirty test for whether the element is displayed if (form.elements[i].clientHeight > 0) { if (form.elements[i].type == 'checkbox') { @@ -140,7 +143,9 @@ function save_changes() { } } } - parent_id.value = JSON.stringify(data); + parent_input.value = JSON.stringify(data); + // update the display + parent.show_fcc_options(); parent.cClick(); //overlib } diff --git a/httemplate/search/477.html b/httemplate/search/477.html old mode 100755 new mode 100644 index ecf21cfb1..68493377d --- a/httemplate/search/477.html +++ b/httemplate/search/477.html @@ -1,135 +1,219 @@ -% if ( $type eq 'xml' ) { -% $filename = "fcc_477_$state" . '_' . time2str('%Y%m%d', $date) . '.xml'; -% http_header('Content-Type' => 'application/XML' ); # So saith RFC 4180 -% http_header('Content-Disposition' => 'attachment;filename="'.$filename.'"'); - - -% } else { #html -<& /elements/header.html, "FCC Form 477 Results - $state" &> -%# XXX when we stop supporting IE8, add this to freeside.css using :nth-child -%# selectors, and remove it from everywhere else +<& /elements/header.html, $title &> - - - - - -% $cgi->param('_type', $type ); - -
- Download full results
-% $cgi->param('_type', 'xml'); - as XML file
- -% $cgi->param('_type', 'html-print'); - as printable copy - -
-% } #html -% foreach my $part ( @parts ) { -% if ( $part{$part} ) { -% -% if ( $part eq 'V' ) { -% next unless ( $part{'IIA'} || $part{'IIB'} ); -% } -% -% if ( $part eq 'VI_census' ) { -% next unless $part{'IA'}; -% } -% -% my @reports = (); -% if ( $part eq 'IA' ) { -% for ( my $tech = 0; $tech < scalar(@technology_option); $tech++ ) { -% next unless $technology_option[$tech]; -% my $url = &{$url_mangler}($part); -% if ( $type eq 'xml' ) { -<<% 'Part_IA_'. chr(65 + $tech) %>> -% } -<& "477part${part}.html", - 'tech_code' => $tech, - 'url' => $url, - 'type' => $type, - 'date' => $date, -&> -% if ( $type eq 'xml' ) { -> -% } -% } -% } else { # not part IA -% if ( $type eq 'xml' ) { -<<% 'Part_'. $part %>> -% } -% my $url = &{$url_mangler}($part); -<& "477part${part}.html", - 'url' => $url, - 'date' => $date, - 'filename' => $filename, -&> -% if ( $type eq 'xml' ) { -> -% } +% foreach my $partnum (@partnums) { +% $cgi->param('parts', $partnum); +% $cgi->param('type', 'csv'); + + +% my $header = ".header$partnum"; +% my $data = $parts{$partnum}; + + <& $header &> + +% #XXX column headings +% foreach my $row (@$data) { + +% foreach my $item (@$row) { + % } + % } -% } -% -% if ( $type eq 'xml' ) { - -% } else { +
+ Part <% $partnum %> + Download +
<% $item %>
+% } # foreach $partnum <& /elements/footer.html &> -% } <%init> +die "access denied" + unless $FS::CurrentUser::CurrentUser->access_right('List packages'); -my $curuser = $FS::CurrentUser::CurrentUser; +my %parts; +# load from cache if possible +my $session; +if ( $cgi->param('session') =~ /^(\d+)$/ ) { + $session = $1; + %parts = %{ $m->cache->get($session) }; +} else { + $session = sprintf('%010d%06d', time, int(rand(1000000))); + $cgi->param('session', $session); +} -die "access denied" - unless $curuser->access_right('List packages'); +my $agentnum; +if ($cgi->param('agentnum') =~ /^(\d+)$/ ) { + $agentnum = $1; +} +my $date = parse_datetime($cgi->param('date')) || time; +my @partnums = grep /^\d+$/, $cgi->param('parts'); +foreach my $partnum (@partnums) { + my $method = "part$partnum"; + $parts{$partnum} ||= FS::Report::FCC_477->$method( + date => $date, + agentnum => $agentnum + ); +} +$m->cache->set($session, \%parts, '1h'); -my $date = $cgi->param('date') ? parse_datetime($cgi->param('date')) - : time; +my $title = 'FCC Form 477 Data - ' . time2str('%b %o, %Y', $date); -my $state = uc($cgi->param('state')); -$state =~ /^[A-Z]{2}$/ or die "illegal state: $state"; +if ( $cgi->param('type') eq 'csv' ) { + my $partnum = $partnums[0]; # ignore any beyond the first + my $data = $parts{$partnum}; + my $csv = Text::CSV_XS->new({ eol => "\r\n" }); # i think -my %part = map { $_ => 1 } grep { /^\w+$/ } $cgi->param('part'); -my $type = $cgi->param('_type') || 'html'; -my $filename; -my @technology_option = &FS::Report::FCC_477::parse_technology_option($cgi,1); + my $filename = time2str('%Y-%m-%d', $date) . '-part' . $partnum . '.csv'; + http_header('Content-Type' => 'text/csv'); + http_header('Content-Disposition' => qq(attachment;filename="$filename")); -# save upload and download mappings -my @download = $cgi->param('part1_column_option'); -my @upload = $cgi->param('part1_row_option'); -for(my $i=0; $i < scalar(@download); $i++) { - &FS::Report::FCC_477::save_fcc477map("part1_column_option_$i",$download[$i]); -} -for(my $i=0; $i < scalar(@upload); $i++) { - &FS::Report::FCC_477::save_fcc477map("part1_row_option_$i",$upload[$i]); -} + $m->clear_buffer; -my @part2a_row_option = $cgi->param('part2a_row_option'); -for(my $i=0; $i < scalar(@part2a_row_option); $i++) { - &FS::Report::FCC_477::save_fcc477map("part2a_row_option_$i",$part2a_row_option[$i]); + foreach my $row (@$data) { + $csv->combine(@$row); + $m->print($csv->string); + } + $m->abort; } -my @part2b_row_option = $cgi->param('part2b_row_option'); -for(my $i=0; $i < scalar(@part2b_row_option); $i++) { - &FS::Report::FCC_477::save_fcc477map("part2b_row_option_$i",$part2b_row_option[$i]); -} + +<%def .header6> + + Census Tract + Technology + Speed (Mbps) + Subscriptions + + + Down + Up + Total + Consumer + + +<%def .header7> + + State + Speed (Mbps) + Subscriptions + + + Down + Up + Total + Consumer + + +<%def .header8> + + State + Subscriptions + + + Total + Direct + + +<%def .header9> + + State + Wholesale + End User Lines + + + VGEs + UNE-Ls -my $part5_report_option = $cgi->param('part5_report_option'); -if ( $part5_report_option ) { - FS::Report::FCC_477::save_fcc477map('part5_report_option', $part5_report_option); -} + Total + With Broadband + Consumer + Business -my $url_mangler = sub { - my $part = shift; - my $url = $cgi->url('-path_info' => 1, '-full' => 1); - $url =~ s/477\./477part$part./; - $url; -}; -my @parts = qw( IA IIA IIB IV V VI_census ); + Local Loop - + Special Media + + + + + +LD + + +LD + + Owned + UNE-L + Resale + + Fiber + Coaxial + Wireless + + +<%def .header10> + + State + VoIP OTT + VoIP Non-OTT + + + Total + Consumer + + Total + Consumer + Bundled + Media Type + + + Copper + Fiber + Coaxial + Wireless + Other + + +<%def .header11> + + Census Tract + VoIP? + Lines/Subscriptions + + + Total + Consumer + + diff --git a/httemplate/search/old477/477.html b/httemplate/search/old477/477.html new file mode 100755 index 000000000..ecf21cfb1 --- /dev/null +++ b/httemplate/search/old477/477.html @@ -0,0 +1,135 @@ +% if ( $type eq 'xml' ) { +% $filename = "fcc_477_$state" . '_' . time2str('%Y%m%d', $date) . '.xml'; +% http_header('Content-Type' => 'application/XML' ); # So saith RFC 4180 +% http_header('Content-Disposition' => 'attachment;filename="'.$filename.'"'); + + +% } else { #html +<& /elements/header.html, "FCC Form 477 Results - $state" &> +%# XXX when we stop supporting IE8, add this to freeside.css using :nth-child +%# selectors, and remove it from everywhere else + + + + + + +% $cgi->param('_type', $type ); + +
+ Download full results
+% $cgi->param('_type', 'xml'); + as XML file
+ +% $cgi->param('_type', 'html-print'); + as printable copy + +
+% } #html +% foreach my $part ( @parts ) { +% if ( $part{$part} ) { +% +% if ( $part eq 'V' ) { +% next unless ( $part{'IIA'} || $part{'IIB'} ); +% } +% +% if ( $part eq 'VI_census' ) { +% next unless $part{'IA'}; +% } +% +% my @reports = (); +% if ( $part eq 'IA' ) { +% for ( my $tech = 0; $tech < scalar(@technology_option); $tech++ ) { +% next unless $technology_option[$tech]; +% my $url = &{$url_mangler}($part); +% if ( $type eq 'xml' ) { +<<% 'Part_IA_'. chr(65 + $tech) %>> +% } +<& "477part${part}.html", + 'tech_code' => $tech, + 'url' => $url, + 'type' => $type, + 'date' => $date, +&> +% if ( $type eq 'xml' ) { +> +% } +% } +% } else { # not part IA +% if ( $type eq 'xml' ) { +<<% 'Part_'. $part %>> +% } +% my $url = &{$url_mangler}($part); +<& "477part${part}.html", + 'url' => $url, + 'date' => $date, + 'filename' => $filename, +&> +% if ( $type eq 'xml' ) { +> +% } +% } +% } +% } +% +% if ( $type eq 'xml' ) { +
+% } else { +<& /elements/footer.html &> +% } +<%init> + +my $curuser = $FS::CurrentUser::CurrentUser; + +die "access denied" + unless $curuser->access_right('List packages'); + +my $date = $cgi->param('date') ? parse_datetime($cgi->param('date')) + : time; + +my $state = uc($cgi->param('state')); +$state =~ /^[A-Z]{2}$/ or die "illegal state: $state"; + +my %part = map { $_ => 1 } grep { /^\w+$/ } $cgi->param('part'); +my $type = $cgi->param('_type') || 'html'; +my $filename; +my @technology_option = &FS::Report::FCC_477::parse_technology_option($cgi,1); + +# save upload and download mappings +my @download = $cgi->param('part1_column_option'); +my @upload = $cgi->param('part1_row_option'); +for(my $i=0; $i < scalar(@download); $i++) { + &FS::Report::FCC_477::save_fcc477map("part1_column_option_$i",$download[$i]); +} +for(my $i=0; $i < scalar(@upload); $i++) { + &FS::Report::FCC_477::save_fcc477map("part1_row_option_$i",$upload[$i]); +} + +my @part2a_row_option = $cgi->param('part2a_row_option'); +for(my $i=0; $i < scalar(@part2a_row_option); $i++) { + &FS::Report::FCC_477::save_fcc477map("part2a_row_option_$i",$part2a_row_option[$i]); +} + +my @part2b_row_option = $cgi->param('part2b_row_option'); +for(my $i=0; $i < scalar(@part2b_row_option); $i++) { + &FS::Report::FCC_477::save_fcc477map("part2b_row_option_$i",$part2b_row_option[$i]); +} + +my $part5_report_option = $cgi->param('part5_report_option'); +if ( $part5_report_option ) { + FS::Report::FCC_477::save_fcc477map('part5_report_option', $part5_report_option); +} + +my $url_mangler = sub { + my $part = shift; + my $url = $cgi->url('-path_info' => 1, '-full' => 1); + $url =~ s/477\./477part$part./; + $url; +}; +my @parts = qw( IA IIA IIB IV V VI_census ); + + diff --git a/httemplate/search/477partIA.html b/httemplate/search/old477/477partIA.html similarity index 100% rename from httemplate/search/477partIA.html rename to httemplate/search/old477/477partIA.html diff --git a/httemplate/search/477partIIA.html b/httemplate/search/old477/477partIIA.html similarity index 100% rename from httemplate/search/477partIIA.html rename to httemplate/search/old477/477partIIA.html diff --git a/httemplate/search/477partIIB.html b/httemplate/search/old477/477partIIB.html similarity index 100% rename from httemplate/search/477partIIB.html rename to httemplate/search/old477/477partIIB.html diff --git a/httemplate/search/477partIV.html b/httemplate/search/old477/477partIV.html similarity index 100% rename from httemplate/search/477partIV.html rename to httemplate/search/old477/477partIV.html diff --git a/httemplate/search/477partV.html b/httemplate/search/old477/477partV.html similarity index 98% rename from httemplate/search/477partV.html rename to httemplate/search/old477/477partV.html index 2ffad2a27..80201f9d7 100755 --- a/httemplate/search/477partV.html +++ b/httemplate/search/old477/477partV.html @@ -1,7 +1,7 @@ % if ( $cgi->param('_type') =~ /^xml$/ ) { % } -<& elements/search.html, +<& /search/elements/search.html, 'html_init' => $html_init, 'name' => 'zip code', 'query' => $sql_query, diff --git a/httemplate/search/477partVI_census.html b/httemplate/search/old477/477partVI_census.html similarity index 99% rename from httemplate/search/477partVI_census.html rename to httemplate/search/old477/477partVI_census.html index 2f3cf419a..efcf4ef1b 100755 --- a/httemplate/search/477partVI_census.html +++ b/httemplate/search/old477/477partVI_census.html @@ -1,4 +1,4 @@ -<& elements/search.html, +<& /search/elements/search.html, 'html_init' => '

Part VI

', 'html_foot' => $html_foot, 'name' => 'regions', diff --git a/httemplate/search/old477/report_477.html b/httemplate/search/old477/report_477.html new file mode 100755 index 000000000..a5dd70b7c --- /dev/null +++ b/httemplate/search/old477/report_477.html @@ -0,0 +1,282 @@ +<% include('/elements/header.html', 'FCC Form 477 Report' ) %> + +
+ + + + + + + + + <% include( '/elements/tr-select-agent.html', + 'curr_value' => scalar( $cgi->param('agentnum') ), + 'disable_empty' => 0, + ) + %> + +% # not tr-select-state, we only want to choose from among those that +% # have customers + <& /elements/tr-select-table.html, + 'label' => 'State', + 'field' => 'state', + 'table' => 'cust_location', + 'name_col' => 'state', + 'value_col' => 'state', + 'disable_empty' => 1, + 'records' => \@states, + &> + + <& /elements/tr-input-date-field.html, { + 'label' => 'As of date', + 'name' => 'date', + 'value' => '', + 'format' => '%m/%d/%Y' + } &> + + <% include( '/elements/tr-select-pkg_class.html', + 'multiple' => 1, + 'empty_label' => '(empty class)', + ) + %> + + + + <% include( '/elements/tr-checkbox.html', + 'label' => 'Enable part IA?', + 'field' => 'part', + 'id' => 'enableIA', + 'value' => 'IA', + 'onchange' => 'partchange(this); toggleVI();', + ) + %> + + + + <% include( '/elements/tr-checkbox.html', + 'label' => 'Enable part IIA?', + 'field' => 'part', + 'id' => 'enableIIA', + 'value' => 'IIA', + 'onchange' => 'partchange(this); toggleV();', + ) + %> + + + + <% include( '/elements/tr-checkbox.html', + 'label' => 'Enable part IIB?', + 'field' => 'part', + 'id' => 'enableIIB', + 'value' => 'IIB', + 'onchange' => 'partchange(this); toggleV();', + ) + %> + + + + <% include( '/elements/tr-checkbox.html', + 'label' => 'Enable part IV?', + 'field' => 'part', + 'id' => 'enableIV', #unused + 'value' => 'IV', + 'onchange' => 'partchange(this)', + ) + %> + + + + <% include( '/elements/tr-checkbox.html', + 'label' => 'Enable part V?', + 'field' => 'part', + 'value' => 'V', + 'id' => 'enableV', + 'onchange' => 'partchange(this)', + 'postfix' => + ' (requires Part IIA or IIB)', + ) + %> + + + + + + + <% include( '/elements/tr-checkbox.html', + 'label' => 'Enable part VI?', + 'field' => 'part', + 'id' => 'enableVI', + 'value' => 'VI_census', + 'postfix' => + ' (requires part IA)', + ) + %> + +
+ Search options +
+ +
+ + +
+ +<% include('/elements/footer.html') %> +<%init> + +die "access denied" + unless $FS::CurrentUser::CurrentUser->access_right('List packages'); + +my @states = qsearch({ + 'table' => 'cust_location', + 'select' => 'DISTINCT(state)', + 'hashref' => { 'country' => 'US' }, # 477 report isn't relevant elsewhere +}); + + diff --git a/httemplate/search/report_477.html b/httemplate/search/report_477.html index a5dd70b7c..e3ae69e6f 100755 --- a/httemplate/search/report_477.html +++ b/httemplate/search/report_477.html @@ -1,32 +1,23 @@ -<% include('/elements/header.html', 'FCC Form 477 Report' ) %> +% if ( $conf->exists('old_fcc_report') ) { +% $m->clear_buffer; +% $m->print($cgi->redirect($fsurl . 'search/old477/report_477.html')); +% $m->abort; +% } +<& /elements/header.html, 'FCC Form 477 Report' &>
- - <% include( '/elements/tr-select-agent.html', - 'curr_value' => scalar( $cgi->param('agentnum') ), - 'disable_empty' => 0, - ) - %> - -% # not tr-select-state, we only want to choose from among those that -% # have customers - <& /elements/tr-select-table.html, - 'label' => 'State', - 'field' => 'state', - 'table' => 'cust_location', - 'name_col' => 'state', - 'value_col' => 'state', - 'disable_empty' => 1, - 'records' => \@states, + <& /elements/tr-select-agent.html, + 'curr_value' => scalar( $cgi->param('agentnum') ), + 'disable_empty' => 0, &> <& /elements/tr-input-date-field.html, { @@ -36,247 +27,31 @@ 'format' => '%m/%d/%Y' } &> - <% include( '/elements/tr-select-pkg_class.html', - 'multiple' => 1, - 'empty_label' => '(empty class)', - ) - %> - - - - <% include( '/elements/tr-checkbox.html', - 'label' => 'Enable part IA?', - 'field' => 'part', - 'id' => 'enableIA', - 'value' => 'IA', - 'onchange' => 'partchange(this); toggleVI();', - ) - %> - - - - <% include( '/elements/tr-checkbox.html', - 'label' => 'Enable part IIA?', - 'field' => 'part', - 'id' => 'enableIIA', - 'value' => 'IIA', - 'onchange' => 'partchange(this); toggleV();', - ) - %> - - - - <% include( '/elements/tr-checkbox.html', - 'label' => 'Enable part IIB?', - 'field' => 'part', - 'id' => 'enableIIB', - 'value' => 'IIB', - 'onchange' => 'partchange(this); toggleV();', - ) - %> - - - - <% include( '/elements/tr-checkbox.html', - 'label' => 'Enable part IV?', - 'field' => 'part', - 'id' => 'enableIV', #unused - 'value' => 'IV', - 'onchange' => 'partchange(this)', - ) - %> - - - - <% include( '/elements/tr-checkbox.html', - 'label' => 'Enable part V?', - 'field' => 'part', - 'value' => 'V', - 'id' => 'enableV', - 'onchange' => 'partchange(this)', - 'postfix' => - ' (requires Part IIA or IIB)', - ) - %> - - - - - - - <% include( '/elements/tr-checkbox.html', - 'label' => 'Enable part VI?', - 'field' => 'part', - 'id' => 'enableVI', - 'value' => 'VI_census', - 'postfix' => - ' (requires part IA)', - ) - %> - + <& /elements/tr-checkbox-multiple.html, + 'label' => 'Enable parts', + 'field' => 'parts', + 'labels' => { + 6 => 'Part 6 (Fixed Broadband Subscription)', + #7 => 'Part 7 (Mobile Wireless Broadband Subscription), + #8 => 'Part 8 (Mobile Local Telephone Subscription), + 9 => 'Part 9 (Local Exchange Telephone Subscription)', + 10 => 'Part 10 (Interconnected VoIP Subscription)', + 11 => 'Part 11 (Voice Telephone Subscription Detail)', + }, + options => [ 6, 9, 10, 11 ], + &>
- Search options + Report options
-
- +
+
-<% include('/elements/footer.html') %> +<& /elements/footer.html &> <%init> die "access denied" unless $FS::CurrentUser::CurrentUser->access_right('List packages'); -my @states = qsearch({ - 'table' => 'cust_location', - 'select' => 'DISTINCT(state)', - 'hashref' => { 'country' => 'US' }, # 477 report isn't relevant elsewhere -}); - +my $conf = FS::Conf->new; -- 2.11.0