diff options
Diffstat (limited to 'FS')
| -rw-r--r-- | FS/FS/ClientAPI/MyAccount.pm | 23 | ||||
| -rw-r--r-- | FS/FS/Conf.pm | 30 | ||||
| -rw-r--r-- | FS/FS/Record.pm | 4 | ||||
| -rw-r--r-- | FS/FS/Report/FCC_477.pm | 119 | ||||
| -rw-r--r-- | FS/FS/Report/Table.pm | 311 | ||||
| -rw-r--r-- | FS/FS/Template_Mixin.pm | 19 | ||||
| -rw-r--r-- | FS/FS/cust_pkg/Search.pm | 98 | ||||
| -rw-r--r-- | FS/FS/deploy_zone.pm | 2 | ||||
| -rw-r--r-- | FS/FS/part_export/nena2.pm | 9 | ||||
| -rw-r--r-- | FS/FS/svc_alarm.pm | 6 | ||||
| -rw-r--r-- | FS/FS/tax_rate.pm | 33 |
11 files changed, 525 insertions, 129 deletions
diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm index e15fb8c22..bcfe35c2a 100644 --- a/FS/FS/ClientAPI/MyAccount.pm +++ b/FS/FS/ClientAPI/MyAccount.pm @@ -398,6 +398,8 @@ sub access_info { $info->{'timeout'} = $conf->config('selfservice-timeout') || 3600; + $info->{'hide_usage'} = $conf->exists('selfservice_hide-usage'); + return { %$info, 'custnum' => $custnum, 'access_pkgnum' => $session->{'pkgnum'}, @@ -751,6 +753,8 @@ sub edit_info { $payby = $1; } + my $conf = new FS::Conf; + if ( $payby =~ /^(CARD|DCRD)$/ ) { $new->paydate($p->{'year'}. '-'. $p->{'month'}. '-01'); @@ -763,6 +767,10 @@ sub edit_info { $new->set( 'payby' => $p->{'auto'} ? 'CARD' : 'DCRD' ); + if ( $conf->exists('selfservice-onfile_require_cvv') ){ + return { 'error' => 'CVV2 is required' } unless $p->{'paycvv'}; + } + } elsif ( $payby =~ /^(CHEK|DCHK)$/ ) { my $payinfo; @@ -839,8 +847,9 @@ sub payment_info { 'card_types' => card_types(), - 'withcvv' => $conf->exists('selfservice-require_cvv'), #or enable optional cvv? - 'require_cvv' => $conf->exists('selfservice-require_cvv'), + 'withcvv' => $conf->exists('selfservice-require_cvv'), #or enable optional cvv? + 'require_cvv' => $conf->exists('selfservice-require_cvv'), + 'onfile_require_cvv' => $conf->exists('selfservice-onfile_require_cvv'), 'paytypes' => [ @FS::cust_main::paytypes ], @@ -1029,6 +1038,8 @@ sub validate_payment { or return { 'error' => "CVV2 (CVC2/CID) is three digits." }; $paycvv = $1; } + } elsif ( $conf->exists('selfservice-onfile_require_cvv') ) { + return { 'error' => 'CVV2 is required' }; } elsif ( !$onfile && $conf->exists('selfservice-require_cvv') ) { return { 'error' => 'CVV2 is required' }; } @@ -1608,6 +1619,7 @@ sub list_pkgs { or return { 'error' => "unknown custnum $custnum" }; my $conf = new FS::Conf; + my $immutable = $conf->exists('selfservice_immutable-package'); # the duplication below is necessary: # 1. to maintain the current buggy behaviour wrt the cust_pkg and part_pkg @@ -1620,6 +1632,7 @@ sub list_pkgs { 'custnum' => $custnum, 'cust_pkg' => [ map { { $_->hash, + immutable => $immutable, part_pkg => [ map $_->hashref, $_->part_pkg ], part_svc => [ map $_->hashref, $_->available_part_svc ], @@ -1652,6 +1665,7 @@ sub list_pkgs { my $primary_cust_svc = $_->primary_cust_svc; +{ $_->hash, $_->part_pkg->hash, + immutable => $immutable, pkg_label => $_->pkg_locale, status => $_->status, statuscolor => $_->statuscolor, @@ -2344,6 +2358,10 @@ sub change_pkg { my($context, $session, $custnum) = _custoragent_session_custnum($p); return { 'error' => $session } if $context eq 'error'; + my $conf = new FS::Conf; + my $immutable = $conf->exists('selfservice_immutable-package'); + return { 'error' => "Package modification disabled" } if $immutable; + my $search = { 'custnum' => $custnum }; $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent'; my $cust_main = qsearchs('cust_main', $search ) @@ -2365,7 +2383,6 @@ sub change_pkg { \@newpkg, ); - my $conf = new FS::Conf; if ( $conf->exists('signup_server-realtime') ) { my $bill_error = _do_bop_realtime( $cust_main, $status, 'no_credit'=>1 ); diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index e56cf3b2d..940303fc5 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -2706,6 +2706,20 @@ and customer address. Include units.', }, { + 'key' => 'backoffice-require_cvv', + 'section' => 'billing', + 'description' => 'Require CVV for manual credit card entry.', + 'type' => 'checkbox', + }, + + { + 'key' => 'selfservice-onfile_require_cvv', + 'section' => 'self-service', + 'description' => 'Require CVV for on-file credit card during self-service payments.', + 'type' => 'checkbox', + }, + + { 'key' => 'selfservice-require_cvv', 'section' => 'self-service', 'description' => 'Require CVV for credit card self-service payments, except for cards on-file.', @@ -2739,6 +2753,22 @@ and customer address. Include units.', }, { + 'key' => 'selfservice_immutable-package', + 'section' => 'self-service', + 'description' => 'Disable package changes in self-service interface.', + 'type' => 'checkbox', + 'per_agent' => 1, + }, + + { + 'key' => 'selfservice_hide-usage', + 'section' => 'self-service', + 'description' => 'Hide usage data in self-service interface.', + 'type' => 'checkbox', + 'per_agent' => 1, + }, + + { 'key' => 'selfservice_process-pkgpart', 'section' => 'billing', 'description' => 'Package to add to each manual credit card and ACH payment entered by the customer themselves in the self-service interface. Enabling this option may be in violation of your merchant agreement(s), so please check it(/them) carefully before enabling this option.', diff --git a/FS/FS/Record.pm b/FS/FS/Record.pm index 4915b96ef..51cb6dc8e 100644 --- a/FS/FS/Record.pm +++ b/FS/FS/Record.pm @@ -2649,8 +2649,8 @@ sub ut_alpha_lower { Check/untaint phone numbers. May be null. If there is an error, returns the error, otherwise returns false. -Takes an optional two-letter ISO country code; without it or with unsupported -countries, ut_phonen simply calls ut_alphan. +Takes an optional two-letter ISO 3166-1 alpha-2 country code; without +it or with unsupported countries, ut_phonen simply calls ut_alphan. =cut diff --git a/FS/FS/Report/FCC_477.pm b/FS/FS/Report/FCC_477.pm index 599b9e036..ff29d1953 100644 --- a/FS/FS/Report/FCC_477.pm +++ b/FS/FS/Report/FCC_477.pm @@ -280,9 +280,18 @@ sub is_mobile_broadband { =item report SECTION, OPTIONS 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). +name strings) as an arrayref of arrayrefs. OPTIONS may contain the following: + +- date: a timestamp value. Packages that were active on that date will be +counted. + +- agentnum: limit to packages with this agent. + +- detail: if true, the report will contain an additional column which contains +the keys of all objects aggregated in the row. + +- ignore_quantity: if true, package quantities will be ignored (only distinct +packages will be counted). =cut @@ -305,7 +314,6 @@ sub fbd_sql { my $class = shift; my %opt = @_; my $date = $opt{date} || time; - warn $date; my $agentnum = $opt{agentnum}; my @select = ( @@ -319,8 +327,9 @@ sub fbd_sql { 'cir_speed_down', 'cir_speed_up', ); - my $from = - 'deploy_zone_block + push @select, 'blocknum' if $opt{detail}; + + my $from = 'deploy_zone_block JOIN deploy_zone USING (zonenum) JOIN agent USING (agentnum)'; my @where = ( @@ -344,15 +353,18 @@ sub fbs_sql { my %opt = @_; my $date = $opt{date} || time; my $agentnum = $opt{agentnum}; + my $q = $opt{ignore_quantity} ? '1' : 'COALESCE(cust_pkg.quantity, 1)'; my @select = ( 'cust_location.censustract', 'technology', 'broadband_downstream', 'broadband_upstream', - 'COUNT(*)', - 'COUNT(is_consumer)', + "SUM($q)", + "SUM(COALESCE(is_consumer,0) * $q)", ); + push @select, "array_to_string(array_agg(pkgnum), ',')" if $opt{detail}; + my $from = 'cust_pkg JOIN cust_location ON (cust_pkg.locationnum = cust_location.locationnum) @@ -387,16 +399,18 @@ sub fvs_sql { my %opt = @_; my $date = $opt{date} || time; my $agentnum = $opt{agentnum}; + my $q = $opt{ignore_quantity} ? '1' : 'COALESCE(cust_pkg.quantity, 1)'; 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)', + "SUM($q * (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)' + "SUM($q * COALESCE(is_consumer,0) * (CASE WHEN is_voip = 1 THEN voip_sessions ELSE phone_lines END))", ); + push @select, "array_to_string(array_agg(pkgnum), ',')" if $opt{detail}; my $from = 'cust_pkg JOIN cust_location ON (cust_pkg.locationnum = cust_location.locationnum) @@ -429,24 +443,27 @@ sub lts_sql { my %opt = @_; my $date = $opt{date} || time; my $agentnum = $opt{agentnum}; + my $q = $opt{ignore_quantity} ? '1' : 'COALESCE(cust_pkg.quantity, 1)'; my @select = ( "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 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)", - "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)", + "SUM($q * phone_vges)", + "SUM($q * phone_circuits)", + "SUM($q * phone_lines)", + "SUM($q * (CASE WHEN is_broadband = 1 THEN phone_lines ELSE 0 END))", + "SUM($q * (CASE WHEN is_consumer = 1 AND phone_longdistance IS NULL THEN phone_lines ELSE 0 END))", + "SUM($q * (CASE WHEN is_consumer = 1 AND phone_longdistance = 1 THEN phone_lines ELSE 0 END))", + "SUM($q * (CASE WHEN is_consumer IS NULL AND phone_longdistance IS NULL THEN phone_lines ELSE 0 END))", + "SUM($q * (CASE WHEN is_consumer IS NULL AND phone_longdistance = 1 THEN phone_lines ELSE 0 END))", + "SUM($q * (CASE WHEN phone_localloop = 'owned' THEN phone_lines ELSE 0 END))", + "SUM($q * (CASE WHEN phone_localloop = 'leased' THEN phone_lines ELSE 0 END))", + "SUM($q * (CASE WHEN phone_localloop = 'resale' THEN phone_lines ELSE 0 END))", + "SUM($q * (CASE WHEN media = 'Fiber' THEN phone_lines ELSE 0 END))", + "SUM($q * (CASE WHEN media = 'Cable Modem' THEN phone_lines ELSE 0 END))", + "SUM($q * (CASE WHEN media = 'Fixed Wireless' THEN phone_lines ELSE 0 END))", ); + push @select, "array_to_string(array_agg(pkgnum),',')" if $opt{detail}; + my $from = 'cust_pkg JOIN cust_location ON (cust_pkg.locationnum = cust_location.locationnum) @@ -481,22 +498,24 @@ sub voip_sql { my %opt = @_; my $date = $opt{date} || time; my $agentnum = $opt{agentnum}; + my $q = $opt{ignore_quantity} ? '1' : 'COALESCE(cust_pkg.quantity, 1)'; 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)", + "SUM($q * (CASE WHEN (voip_lastmile IS NULL) THEN 1 ELSE 0 END))", + "SUM($q * (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)", + "SUM($q * (CASE WHEN (voip_lastmile = 1) THEN 1 ELSE 0 END))", + "SUM($q * (CASE WHEN (voip_lastmile = 1 AND is_consumer = 1) THEN 1 ELSE 0 END))", + "SUM($q * (CASE WHEN (voip_lastmile = 1 AND is_broadband = 1) THEN 1 ELSE 0 END))", + "SUM($q * (CASE WHEN (voip_lastmile = 1 AND media = 'Copper') THEN 1 ELSE 0 END))", + "SUM($q * (CASE WHEN (voip_lastmile = 1 AND media = 'Cable Modem') THEN 1 ELSE 0 END))", + "SUM($q * (CASE WHEN (voip_lastmile = 1 AND media = 'Fiber') THEN 1 ELSE 0 END))", + "SUM($q * (CASE WHEN (voip_lastmile = 1 AND media = 'Fixed Wireless') THEN 1 ELSE 0 END))", + "SUM($q * (CASE WHEN (voip_lastmile = 1 AND media NOT IN('Copper', 'Fiber', 'Cable Modem', 'Fixed Wireless') ) THEN 1 ELSE 0 END))", ); + push @select, "array_to_string(array_agg(pkgnum),',')" if $opt{detail}; my $from = 'cust_pkg @@ -530,14 +549,17 @@ sub mbs_sql { my %opt = @_; my $date = $opt{date} || time; my $agentnum = $opt{agentnum}; + my $q = $opt{ignore_quantity} ? '1' : 'COALESCE(cust_pkg.quantity, 1)'; my @select = ( 'state.fips', 'broadband_downstream', 'broadband_upstream', - 'COUNT(*)', - 'COUNT(is_consumer)', + "SUM($q)", + "SUM(COALESCE(is_consumer, 0) * $q)", ); + push @select, "array_to_string(array_agg(pkgnum),',')" if $opt{detail}; + my $from = 'cust_pkg JOIN cust_location ON (cust_pkg.locationnum = cust_location.locationnum) @@ -571,12 +593,15 @@ sub mvs_sql { my %opt = @_; my $date = $opt{date} || time; my $agentnum = $opt{agentnum}; + my $q = $opt{ignore_quantity} ? '1' : 'COALESCE(cust_pkg.quantity, 1)'; my @select = ( 'state.fips', - 'COUNT(*)', - 'COUNT(mobile_direct)', + "SUM($q)", + "SUM($q * COALESCE(mobile_direct,0))", ); + push @select, "array_to_string(array_agg(pkgnum),',')" if $opt{detail}; + my $from = 'cust_pkg JOIN cust_location ON (cust_pkg.locationnum = cust_location.locationnum) @@ -625,4 +650,22 @@ sub parts { Storable::dclone(\%parts); } +=item part_table SECTION + +Returns the name of the primary table that's aggregated in the report section +SECTION. The last column of the report returned by the L</report> method is +a comma-separated list of record numbers, in this table, that are included in +the report line item. + +=cut + +sub part_table { + my ($class, $part) = @_; + if ($part eq 'fbd') { + return 'deploy_zone_block'; + } else { + return 'cust_pkg'; + } # add other cases as we add more of the deployment/availability reports +} + 1; diff --git a/FS/FS/Report/Table.pm b/FS/FS/Report/Table.pm index 8c45ae145..98f66e904 100644 --- a/FS/FS/Report/Table.pm +++ b/FS/FS/Report/Table.pm @@ -1,15 +1,25 @@ package FS::Report::Table; use strict; -use vars qw( @ISA $DEBUG ); -use FS::Report; +use base 'FS::Report'; use Time::Local qw( timelocal ); use FS::UID qw( dbh driver_name ); use FS::Report::Table; use FS::CurrentUser; +use Cache::FileCache; -$DEBUG = 0; # turning this on will trace all SQL statements, VERY noisy -@ISA = qw( FS::Report ); +our $DEBUG = 0; # turning this on will trace all SQL statements, VERY noisy + +our $CACHE; # feel free to use this for whatever + +FS::UID->install_callback(sub { + $CACHE = Cache::FileCache->new( { + 'namespace' => __PACKAGE__, + 'cache_root' => "$FS::UID::cache_dir/cache.$FS::UID::datasrc", + } ); + # reset this on startup (causes problems with database backups, etc.) + $CACHE->remove('tower_pkg_cache_update'); +}); =head1 NAME @@ -408,6 +418,8 @@ unspecified, defaults to all three. 'use_override': for line items generated by an add-on package, use the class of the add-on rather than the base package. +'average_per_cust_pkg': divide the result by the number of distinct packages. + 'distribute': for non-monthly recurring charges, ignore the invoice date. Instead, consider the line item's starting/ending dates. Determine the fraction of the line item duration that falls within the specified @@ -428,6 +440,12 @@ sub cust_bill_pkg { $sum += $self->cust_bill_pkg_setup(@_) if $charges{S}; $sum += $self->cust_bill_pkg_recur(@_) if $charges{R}; $sum += $self->cust_bill_pkg_detail(@_) if $charges{U}; + + if ($opt{'average_per_cust_pkg'}) { + my $count = $self->cust_bill_pkg_count_pkgnum(@_); + return '' if $count == 0; + $sum = sprintf('%.2f', $sum / $count); + } $sum; } @@ -454,13 +472,10 @@ sub cust_bill_pkg_setup { $self->with_classnum($opt{'classnum'}, $opt{'use_override'}), $self->with_report_option(%opt), $self->in_time_period_and_agent($speriod, $eperiod, $agentnum), + $self->with_refnum(%opt), + $self->with_cust_classnum(%opt) ); - # yuck, false laziness - push @where, "cust_main.refnum = ". $opt{'refnum'} if $opt{'refnum'}; - - push @where, $self->with_cust_classnum(%opt); - my $total_sql = "SELECT COALESCE(SUM(cust_bill_pkg.setup),0) FROM cust_bill_pkg $cust_bill_pkg_join @@ -469,7 +484,9 @@ sub cust_bill_pkg_setup { $self->scalar_sql($total_sql); } -sub cust_bill_pkg_recur { +sub _cust_bill_pkg_recurring { + # returns the FROM/WHERE part of the statement to query all recurring + # line items in the period my $self = shift; my ($speriod, $eperiod, $agentnum, %opt) = @_; @@ -480,11 +497,35 @@ sub cust_bill_pkg_recur { '(pkgnum != 0 OR feepart IS NOT NULL)', $self->with_classnum($opt{'classnum'}, $opt{'use_override'}), $self->with_report_option(%opt), + $self->with_refnum(%opt), + $self->with_cust_classnum(%opt) ); - push @where, 'cust_main.refnum = '. $opt{'refnum'} if $opt{'refnum'}; + if ( $opt{'distribute'} ) { + $where[0] = 'pkgnum != 0'; # specifically exclude fees + push @where, "cust_main.agentnum = $agentnum" if $agentnum; + push @where, + "$cust_bill_pkg.sdate < $eperiod", + "$cust_bill_pkg.edate >= $speriod", + ; + } + else { + # we don't want to have to create v_cust_bill + my $_date = $opt{'project'} ? 'v_cust_bill_pkg._date' : 'cust_bill._date'; + push @where, + $self->in_time_period_and_agent($speriod, $eperiod, $agentnum, $_date); + } - push @where, $self->with_cust_classnum(%opt); + return " + FROM $cust_bill_pkg + $cust_bill_pkg_join + WHERE ".join(' AND ', grep $_, @where); + +} + +sub cust_bill_pkg_recur { + my $self = shift; + my ($speriod, $eperiod, $agentnum, %opt) = @_; # subtract all usage from the line item regardless of date my $item_usage; @@ -496,33 +537,30 @@ sub cust_bill_pkg_recur { FROM cust_bill_pkg_detail WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum )'; } - my $recur_fraction = ''; + + my $cust_bill_pkg = $opt{'project'} ? 'v_cust_bill_pkg' : 'cust_bill_pkg'; - if ( $opt{'distribute'} ) { - $where[0] = 'pkgnum != 0'; # specifically exclude fees - push @where, "cust_main.agentnum = $agentnum" if $agentnum; - push @where, - "$cust_bill_pkg.sdate < $eperiod", - "$cust_bill_pkg.edate >= $speriod", - ; + my $recur_fraction = ''; + if ($opt{'distribute'}) { # the fraction of edate - sdate that's within [speriod, eperiod] $recur_fraction = " * CAST(LEAST($eperiod, $cust_bill_pkg.edate) - GREATEST($speriod, $cust_bill_pkg.sdate) AS DECIMAL) / ($cust_bill_pkg.edate - $cust_bill_pkg.sdate)"; } - else { - # we don't want to have to create v_cust_bill - my $_date = $opt{'project'} ? 'v_cust_bill_pkg._date' : 'cust_bill._date'; - push @where, - $self->in_time_period_and_agent($speriod, $eperiod, $agentnum, $_date); - } - my $total_sql = 'SELECT '. - "COALESCE(SUM(($cust_bill_pkg.recur - $item_usage) $recur_fraction),0) - FROM $cust_bill_pkg - $cust_bill_pkg_join - WHERE ".join(' AND ', grep $_, @where); + my $total_sql = + "SELECT COALESCE(SUM(($cust_bill_pkg.recur - $item_usage) $recur_fraction),0)" . + $self->_cust_bill_pkg_recurring(@_); + + $self->scalar_sql($total_sql); +} + +sub cust_bill_pkg_count_pkgnum { + # for ARPU calculation + my $self = shift; + my $total_sql = 'SELECT COUNT(DISTINCT pkgnum) '. + $self->_cust_bill_pkg_recurring(@_); $self->scalar_sql($total_sql); } @@ -541,16 +579,14 @@ sub cust_bill_pkg_detail { my @where = ( "(cust_bill_pkg.pkgnum != 0 OR cust_bill_pkg.feepart IS NOT NULL)" ); - push @where, 'cust_main.refnum = '. $opt{'refnum'} if $opt{'refnum'}; - - push @where, $self->with_cust_classnum(%opt); - $agentnum ||= $opt{'agentnum'}; push @where, $self->with_classnum($opt{'classnum'}, $opt{'use_override'}), $self->with_usageclass($opt{'usageclass'}), $self->with_report_option(%opt), + $self->with_refnum(%opt), + $self->with_cust_classnum(%opt) ; if ( $opt{'distribute'} ) { @@ -570,10 +606,6 @@ sub cust_bill_pkg_detail { my $total_sql = " SELECT SUM(cust_bill_pkg_detail.amount) "; $total_sql .= - " / CASE COUNT(cust_pkg.*) WHEN 0 THEN 1 ELSE COUNT(cust_pkg.*) END " - if $opt{average_per_cust_pkg}; - - $total_sql .= " FROM cust_bill_pkg_detail LEFT JOIN cust_bill_pkg USING ( billpkgnum ) LEFT JOIN cust_bill ON cust_bill_pkg.invnum = cust_bill.invnum @@ -616,10 +648,6 @@ sub cust_bill_pkg_discount { my $total_sql = " SELECT COALESCE( SUM( cust_bill_pkg_discount.amount ), 0 ) "; - #$total_sql .= - # " / CASE COUNT(cust_pkg.*) WHEN 0 THEN 1 ELSE COUNT(cust_pkg.*) END " - # if $opt{average_per_cust_pkg}; - $total_sql .= " FROM cust_bill_pkg_discount LEFT JOIN cust_bill_pkg USING ( billpkgnum ) @@ -636,22 +664,91 @@ sub cust_bill_pkg_discount { } -sub setup_pkg { shift->pkg_field( 'setup', @_ ); } -sub susp_pkg { shift->pkg_field( 'susp', @_ ); } -sub cancel_pkg { shift->pkg_field( 'cancel', @_ ); } - -sub pkg_field { - my( $self, $field, $speriod, $eperiod, $agentnum ) = @_; - $self->scalar_sql(" - SELECT COUNT(*) FROM cust_pkg - LEFT JOIN cust_main USING ( custnum ) - WHERE ". $self->in_time_period_and_agent( $speriod, - $eperiod, - $agentnum, - "cust_pkg.$field", - ) +sub pkg_field_where { + my( $self, $field, $speriod, $eperiod, $agentnum, %opt ) = @_; + # someday this will use an aggregate query and return all the columns + # at once + # and I will drive a Tesla and have a live-in sushi chef who is also a + # ninja bodyguard + my @where = ( + $self->in_time_period_and_agent($speriod, + $eperiod, + $agentnum, + "cust_pkg.$field", + ), + $self->with_refnum(%opt), + $self->with_towernum(%opt), + $self->with_zip(%opt), + # can't use with_classnum here... ); + if ($opt{classnum}) { + my $classnum = $opt{classnum}; + $classnum = [ $classnum ] if !ref($classnum); + @$classnum = grep /^\d+$/, @$classnum; + my $in = 'IN ('. join(',', @$classnum). ')'; + push @where, "COALESCE(part_pkg.classnum, 0) $in" if scalar @$classnum; + } + + ' WHERE ' . join(' AND ', grep $_, @where); +} + +=item setup_pkg: The number of packages with setup dates in the period. + +This excludes packages created by package changes. Options: + +- refnum: Limit to customers with this advertising source. +- classnum: Limit to packages with this class. +- towernum: Limit to packages that have a broadband service with this tower. +- zip: Limit to packages with this service location zip code. + +Except for zip, any of these can be an arrayref to allow multiple values for +the field. +=item susp_pkg: The number of suspended packages that were last suspended +in the period. Options are as for setup_pkg. + +=item cancel_pkg: The number of packages with cancel dates in the period. +Excludes packages that were canceled to be changed to a new package. Options +are as for setup_pkg. + +=cut + +sub setup_pkg { + my $self = shift; + my $sql = 'SELECT COUNT(*) FROM cust_pkg + LEFT JOIN part_pkg USING (pkgpart) + LEFT JOIN cust_main USING (custnum)'. + $self->pkg_field_where('setup', @_) . + ' AND change_pkgnum IS NULL'; + + $self->scalar_sql($sql); +} + +sub susp_pkg { + # number of currently suspended packages that were suspended in the period + my $self = shift; + my $sql = 'SELECT COUNT(*) FROM cust_pkg + LEFT JOIN part_pkg USING (pkgpart) + LEFT JOIN cust_main USING (custnum) '. + $self->pkg_field_where('susp', @_); + + $self->scalar_sql($sql); +} + +sub cancel_pkg { + # number of packages canceled in the period and not changed to another + # package + my $self = shift; + my $sql = 'SELECT COUNT(*) FROM cust_pkg + LEFT JOIN part_pkg USING (pkgpart) + LEFT JOIN cust_main USING (custnum) + LEFT JOIN cust_pkg changed_to_pkg ON( + cust_pkg.pkgnum = changed_to_pkg.change_pkgnum + ) '. + $self->pkg_field_where('cancel', @_) . + ' AND changed_to_pkg.pkgnum IS NULL'; + + $self->scalar_sql($sql); } #this is going to be harder.. @@ -686,8 +783,11 @@ sub for_opts { if ( $opt{'custnum'} =~ /^(\d+)$/ ) { $sql .= " and custnum = $1 "; } - if ( $opt{'refnum'} =~ /^(\d+)$/ ) { - $sql .= " and refnum = $1 "; + if ( $opt{'refnum'} ) { + my $refnum = $opt{'refnum'}; + $refnum = [ $refnum ] if !ref($refnum); + my $in = join(',', grep /^\d+$/, @$refnum); + $sql .= " and refnum IN ($in)" if length $in; } if ( my $where = $self->with_cust_classnum(%opt) ) { $sql .= " and $where"; @@ -796,6 +896,49 @@ sub with_report_option { } +sub with_refnum { + my ($self, %opt) = @_; + if ( $opt{'refnum'} ) { + my $refnum = $opt{'refnum'}; + $refnum = [ $refnum ] if !ref($refnum); + my $in = join(',', grep /^\d+$/, @$refnum); + return "cust_main.refnum IN ($in)" if length $in; + } + return; +} + +sub with_towernum { + my ($self, %opt) = @_; + if ( $opt{'towernum'} ) { + my $towernum = $opt{'towernum'}; + $towernum = [ $towernum ] if !ref($towernum); + my $in = join(',', grep /^\d+$/, @$towernum); + return unless length($in); # if no towers are specified, don't restrict + + # materialize/cache the set of pkgnums that, as of the last + # svc_broadband history record, had a certain towernum + # (because otherwise this is painfully slow) + $self->_init_tower_pkg_cache; + + return "EXISTS( + SELECT 1 FROM tower_pkg_cache + WHERE towernum IN($in) + AND cust_pkg.pkgnum = tower_pkg_cache.pkgnum + )"; + } + return; +} + +sub with_zip { + my ($self, %opt) = @_; + if (length($opt{'zip'})) { + return "(SELECT zip FROM cust_location + WHERE cust_location.locationnum = cust_pkg.locationnum + ) = " . dbh->quote($opt{'zip'}); + } + return; +} + sub with_cust_classnum { my ($self, %opt) = @_; if ( $opt{'cust_classnum'} ) { @@ -805,7 +948,7 @@ sub with_cust_classnum { return 'cust_main.classnum in('. join(',',@$classnums) .')' if @$classnums; } - (); + return; } @@ -939,6 +1082,54 @@ sub extend_projection { } } +=item _init_tower_pkg_cache + +Internal method: creates a temporary table relating pkgnums to towernums. +A (pkgnum, towernum) record indicates that this package once had a +svc_broadband service which, as of its last insert or replace_new history +record, had a sectornum associated with that towernum. + +This is expensive, so it won't be done more than once an hour. Historical +data about package churn shouldn't be changing in realtime anyway. + +=cut + +sub _init_tower_pkg_cache { + my $self = shift; + my $dbh = dbh; + + my $current = $CACHE->get('tower_pkg_cache_update'); + return if $current; + + # XXX or should this be in the schema? + my $sql = "DROP TABLE IF EXISTS tower_pkg_cache"; + $dbh->do($sql) or die $dbh->errstr; + $sql = "CREATE TABLE tower_pkg_cache (towernum int, pkgnum int)"; + $dbh->do($sql) or die $dbh->errstr; + + # assumptions: + # sectornums never get reused, or move from one tower to another + # all service history is intact + # svcnums never get reused (this would be bad) + # pkgnums NEVER get reused (this would be extremely bad) + $sql = "INSERT INTO tower_pkg_cache ( + SELECT COALESCE(towernum,0), pkgnum + FROM ( SELECT DISTINCT pkgnum, svcnum FROM h_cust_svc ) AS pkgnum_svcnum + LEFT JOIN ( + SELECT DISTINCT ON(svcnum) svcnum, sectornum + FROM h_svc_broadband + WHERE (history_action = 'replace_new' + OR history_action = 'replace_old') + ORDER BY svcnum ASC, history_date DESC + ) AS svcnum_sectornum USING (svcnum) + LEFT JOIN tower_sector USING (sectornum) + )"; + $dbh->do($sql) or die $dbh->errstr; + + $CACHE->set('tower_pkg_cache_update', 1, 3600); + +}; + =head1 BUGS Documentation. diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm index 06b3d9e7b..d652d5349 100644 --- a/FS/FS/Template_Mixin.pm +++ b/FS/FS/Template_Mixin.pm @@ -2031,27 +2031,20 @@ sub _items_sections { ! $cust_bill_pkg->feepart and ! $section; - if (! $type || $type eq 'S') { + if ( $type eq 'S' ) { $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup if $cust_bill_pkg->setup != 0 || $cust_bill_pkg->setup_show_zero; - } - - if (! $type) { - $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - if $cust_bill_pkg->recur != 0 - || $cust_bill_pkg->recur_show_zero; - } - - if ($type && $type eq 'R') { + } elsif ( $type eq 'R' ) { $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage if $cust_bill_pkg->recur != 0 || $cust_bill_pkg->recur_show_zero; - } - - if ($type && $type eq 'U') { + } elsif ( $type eq 'U' ) { $subtotal{$locationnum}{$section} += $usage unless scalar(@extra_sections); + } elsif ( !$type ) { + $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup + + $cust_bill_pkg->recur; } } diff --git a/FS/FS/cust_pkg/Search.pm b/FS/FS/cust_pkg/Search.pm index 1a9132df6..77196568b 100644 --- a/FS/FS/cust_pkg/Search.pm +++ b/FS/FS/cust_pkg/Search.pm @@ -112,6 +112,21 @@ Limit to packages whose locations have geocodes. Limit to packages whose locations do not have geocodes. +=item towernum + +Limit to packages associated with a svc_broadband, associated with a sector, +associated with this towernum (or any of these, if it's an arrayref) (or NO +towernum, if it's zero). This is an extreme niche case. + +=item 477part, 477rownum, date + +Limit to packages included in a specific row of one of the FCC 477 reports. +'477part' is the section name (see L<FS::Report::FCC_477> methods), 'date' +is the report as-of date (completely unrelated to the package setup/bill/ +other date fields), and '477rownum' is the row number of the report starting +with zero. Row numbers have no inherent meaning, so this is useful only +for explaining a 477 report you've already run. + =back =cut @@ -351,7 +366,7 @@ sub search { } ### - # parse country/state + # parse country/state/zip ### for (qw(state country)) { # parsing rules are the same for these if ( exists($params->{$_}) @@ -361,6 +376,9 @@ sub search { push @where, "cust_location.$_ = '$1'"; } } + if ( exists($params->{zip}) ) { + push @where, "cust_location.zip = " . dbh->quote($params->{zip}); + } ### # location_* flags @@ -433,6 +451,9 @@ sub search { "NOT (".FS::cust_pkg->onetime_sql . ")"; } else { + my $exclude_change_from = 0; + my $exclude_change_to = 0; + foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end change_date cancel )) { next unless exists($params->{$field}); @@ -448,6 +469,27 @@ sub search { $orderby ||= "ORDER BY cust_pkg.$field"; + if ( $field eq 'setup' ) { + $exclude_change_from = 1; + } elsif ( $field eq 'cancel' ) { + $exclude_change_to = 1; + } elsif ( $field eq 'change_date' ) { + # if we are given setup and change_date ranges, and the setup date + # falls in _both_ ranges, then include the package whether it was + # a change or not + $exclude_change_from = 0; + } + } + + if ($exclude_change_from) { + push @where, "change_pkgnum IS NULL"; + } + if ($exclude_change_to) { + # a join might be more efficient here + push @where, "NOT EXISTS( + SELECT 1 FROM cust_pkg AS changed_to_pkg + WHERE cust_pkg.pkgnum = changed_to_pkg.change_pkgnum + )"; } } @@ -487,6 +529,60 @@ sub search { } ## + # parse the extremely weird 'towernum' param + ## + + if ($params->{towernum}) { + my $towernum = $params->{towernum}; + $towernum = [ $towernum ] if !ref($towernum); + my $in = join(',', grep /^\d+$/, @$towernum); + if (length $in) { + # inefficient, but this is an obscure feature + eval "use FS::Report::Table"; + FS::Report::Table->_init_tower_pkg_cache; # probably does nothing + push @where, "EXISTS( + SELECT 1 FROM tower_pkg_cache + WHERE tower_pkg_cache.pkgnum = cust_pkg.pkgnum + AND tower_pkg_cache.towernum IN ($in) + )" + } + } + + ## + # parse the 477 report drill-down options + ## + + if ($params->{'477part'} =~ /^([a-z]+)$/) { + my $section = $1; + my ($date, $rownum, $agentnum); + if ($params->{'date'} =~ /^(\d+)$/) { + $date = $1; + } + if ($params->{'477rownum'} =~ /^(\d+)$/) { + $rownum = $1; + } + if ($params->{'agentnum'} =~ /^(\d+)$/) { + $agentnum = $1; + } + if ($date and defined($rownum)) { + my $report = FS::Report::FCC_477->report($section, + 'date' => $date, + 'agentnum' => $agentnum, + 'detail' => 1 + ); + my $row = $report->[$rownum] + or die "row $rownum is past the end of the report"; + my $pkgnums = $row->[-1] || '0'; + # '0' so that if there are no pkgnums (empty string) it will create + # a valid query that returns nothing + warn "PKGNUMS:\n$pkgnums\n\n"; # XXX debug + + # and this overrides everything + @where = ( "cust_pkg.pkgnum IN($pkgnums)" ); + } # else we're missing some params, ignore the whole business + } + + ## # setup queries, links, subs, etc. for the search ## diff --git a/FS/FS/deploy_zone.pm b/FS/FS/deploy_zone.pm index 16f59c81d..6142b9181 100644 --- a/FS/FS/deploy_zone.pm +++ b/FS/FS/deploy_zone.pm @@ -181,7 +181,7 @@ sub check { my $error = $self->ut_numbern('zonenum') - || $self->ut_textn('description') + || $self->ut_text('description') || $self->ut_number('agentnum') || $self->ut_foreign_key('agentnum', 'agent', 'agentnum') || $self->ut_textn('dbaname') diff --git a/FS/FS/part_export/nena2.pm b/FS/FS/part_export/nena2.pm index 4d3f08f98..8d59425b7 100644 --- a/FS/FS/part_export/nena2.pm +++ b/FS/FS/part_export/nena2.pm @@ -263,6 +263,15 @@ sub data { # customer name and class $hash{customer_name} = $svc->phone_name_or_cust; $hash{class_of_service} = $svc->e911_class; + if (!$hash{class_of_service}) { + # then guess + my $cust_main = $svc->cust_main; + if ($cust_main->company) { + $hash{class_of_service} = '2'; + } else { + $hash{class_of_service} = '1'; + } + } $hash{type_of_service} = $svc->e911_type || '0'; $hash{exchange} = ''; diff --git a/FS/FS/svc_alarm.pm b/FS/FS/svc_alarm.pm index afb4c1db3..1c1754d81 100644 --- a/FS/FS/svc_alarm.pm +++ b/FS/FS/svc_alarm.pm @@ -74,7 +74,7 @@ sub table_info { 'acctnum' => { label => 'Account #', %opts }, '_password' => { label => 'Password' , %opts }, 'location' => { label => 'Location', %opts }, - 'cs_receiver' => { label => 'CS Reciever #'}, + 'cs_receiver' => { label => 'CS Receiver #'}, 'cs_phonenum' => { label => 'CS Phone #' }, 'serialnum' => { label => 'Alarm Serial #' }, 'alarmsystemnum' => { label => 'Alarm System Vendor', @@ -197,13 +197,15 @@ sub check { my $x = $self->setfixed; return $x unless ref $x; + my $iso3166 = $self->cust_main->ship_location->country(); + my $error = $self->ut_numbern('svcnum') || $self->ut_text('acctnum') || $self->ut_alphan('_password') || $self->ut_textn('location') || $self->ut_numbern('cs_receiver') - || $self->ut_phonen('cs_phonenum') + || $self->ut_phonen('cs_phonenum', $iso3166) || $self->ut_alphan('serialnum') || $self->ut_foreign_key('alarmsystemnum', 'alarm_system', 'systemnum') || $self->ut_foreign_key('alarmtypenum', 'alarm_type', 'typenum') diff --git a/FS/FS/tax_rate.pm b/FS/FS/tax_rate.pm index 6bc5e18e9..50279172d 100644 --- a/FS/FS/tax_rate.pm +++ b/FS/FS/tax_rate.pm @@ -286,16 +286,25 @@ sub unittype_name { =item maxtype_name -Returns the human understandable value associated with the maxtype column +Returns the human understandable value associated with the maxtype column. =cut +# XXX these are non-functional, and most of them are horrible to implement +# in our current model + %tax_maxtypes = ( '0' => 'receipts per invoice', '1' => 'receipts per item', '2' => 'total utility charges per utility tax year', '3' => 'total charges per utility tax year', '4' => 'receipts per access line', + '7' => 'total utility charges per calendar year', '9' => 'monthly receipts per location', + '10' => 'monthly receipts exceeds taxbase and total tax per month does not exceed maxtax', # wtf? + '11' => 'receipts/units per access line', + '14' => 'units per invoice', + '15' => 'units per month', + '18' => 'units per account', ); sub maxtype_name { @@ -423,17 +432,12 @@ sub taxline { } my $maxtype = $self->maxtype || 0; - if ($maxtype != 0 && $maxtype != 1 && $maxtype != 9) { + if ($maxtype != 0 && $maxtype != 1 + && $maxtype != 14 && $maxtype != 15) { return $self->_fatal_or_null( 'tax with "'. $self->maxtype_name. '" threshold' ); - } - - if ($maxtype == 9) { - return - $self->_fatal_or_null( 'tax with "'. $self->maxtype_name. '" threshold' ); - # "texas" tax - } + } # I don't know why, it's not like there are maxtypes that we DO support # we treat gross revenue as gross receipts and expect the tax data # to DTRT (i.e. tax on tax rules) @@ -493,6 +497,15 @@ sub taxline { # the tax or fee is applied to taxbase or feebase and then # the excessrate or excess fee is applied to taxmax or feemax + if ( ($self->taxmax > 0 and $taxable_charged > $self->taxmax) or + ($self->feemax > 0 and $taxable_units > $self->feemax) ) { + # throw an error + # (why not just cap taxable_charged/units at the taxmax/feemax? because + # it's way more complicated than that. this won't even catch every case + # where a bracket maximum should apply.) + return $self->_fatal_or_null( 'tax base > taxmax/feemax for tax'.$self->taxnum ); + } + $amount += $taxable_charged * $self->tax; $amount += $taxable_units * $self->fee; @@ -509,6 +522,8 @@ sub taxline { sub _fatal_or_null { my ($self, $error) = @_; + $DB::single = 1; # not a mistake + my $conf = new FS::Conf; $error = "can't yet handle ". $error; |
