From bda8c33f9b346ba6cd7aa4174ce0d3e37db7bd49 Mon Sep 17 00:00:00 2001 From: ivan Date: Mon, 14 Nov 2011 04:31:32 +0000 Subject: [PATCH] rate tiers for vnes, RT#14903 --- FS/FS.pm | 4 + FS/FS/Schema.pm | 53 ++++-- FS/FS/cdr.pm | 30 +++- FS/FS/cust_bill_pkg.pm | 23 ++- FS/FS/part_pkg/voip_inbound.pm | 55 +++--- FS/FS/part_pkg/voip_tiered.pm | 258 +++++++++++++++++++++++++++ FS/FS/rate_tier.pm | 153 ++++++++++++++++ FS/FS/rate_tier_detail.pm | 139 +++++++++++++++ FS/MANIFEST | 4 + FS/t/rate_tier.t | 5 + FS/t/rate_tier_detail.t | 5 + httemplate/browse/rate_tier.html | 53 ++++++ httemplate/edit/elements/edit.html | 2 +- httemplate/edit/process/rate_tier.html | 15 ++ httemplate/edit/rate_tier.html | 54 ++++++ httemplate/elements/menu.html | 4 +- httemplate/elements/rate_tier_detail.html | 66 +++++++ httemplate/elements/tr-rate_tier_detail.html | 24 +++ 18 files changed, 893 insertions(+), 54 deletions(-) create mode 100644 FS/FS/part_pkg/voip_tiered.pm create mode 100644 FS/FS/rate_tier.pm create mode 100644 FS/FS/rate_tier_detail.pm create mode 100644 FS/t/rate_tier.t create mode 100644 FS/t/rate_tier_detail.t create mode 100644 httemplate/browse/rate_tier.html create mode 100644 httemplate/edit/process/rate_tier.html create mode 100644 httemplate/edit/rate_tier.html create mode 100644 httemplate/elements/rate_tier_detail.html create mode 100644 httemplate/elements/tr-rate_tier_detail.html diff --git a/FS/FS.pm b/FS/FS.pm index 8645c56cb..e8f2cdc33 100644 --- a/FS/FS.pm +++ b/FS/FS.pm @@ -250,6 +250,10 @@ L - Rate region prefixes for call billing L - Rate plan detail for call billing +L - Rate tiers for call billing + +L - Rater tier details for call billing + L - Usage class class L - Agent (reseller) class diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index 87bdc2647..0568bdf1d 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -2688,6 +2688,30 @@ sub tables_hashref { 'index' => [], }, + #not really part of the above rate_ stuff (used with flat rate rather than + # rated billing), but could be eventually, and its a rate + 'rate_tier' => { + 'columns' => [ + 'tiernum', 'serial', '', '', '', '', + 'tiername', 'varchar', '', $char_d, '', '', + ], + 'primary_key' => 'tiernum', + 'unique' => [ [ 'tiername'], ], + 'index' => [], + }, + + 'rate_tier_detail' => { + 'columns' => [ + 'tierdetailnum', 'serial', '', '', '', '', + 'tiernum', 'int', '', '', '', '', + 'min_quan', 'int', '', '', '', '', + 'min_charge', 'decimal', '', '10,4', '', '', + ], + 'primary_key' => 'tierdetailnum', + 'unique' => [], + 'index' => [ ['tiernum'], ], + }, + 'usage_class' => { 'columns' => [ 'classnum', 'serial', '', '', '', '', @@ -2875,24 +2899,17 @@ sub tables_hashref { 'max_callers', 'int', 'NULL', '', '', '', ### - # fields for unitel/RSLCOM/convergent that don't map well to asterisk - # defaults - # though these are now used elsewhere: + # old fields for unitel/RSLCOM/convergent that don't map to asterisk + # ones we adoped moved to "own fields" section below # charged_party, upstream_price, rated_price, carrierid, cdrtypenum ### - #cdr_type: Usage = 1, S&E = 7, OC&C = 8 - 'cdrtypenum', 'int', 'NULL', '', '', '', - - 'charged_party', 'varchar', 'NULL', $char_d, '', '', - 'upstream_currency', 'char', 'NULL', 3, '', '', 'upstream_price', 'decimal', 'NULL', '10,4', '', '', 'upstream_rateplanid', 'int', 'NULL', '', '', '', #? # how it was rated internally... 'ratedetailnum', 'int', 'NULL', '', '', '', - 'rated_price', 'decimal', 'NULL', '10,4', '', '', 'distance', 'decimal', 'NULL', '', '', '', 'islocal', 'int', 'NULL', '', '', '', # '', '', 0, '' instead? @@ -2903,16 +2920,24 @@ sub tables_hashref { 'description', 'varchar', 'NULL', $char_d, '', '', 'quantity', 'int', 'NULL', '', '', '', - #cdr_carrier: Telstra =1, Optus = 2, RSL COM = 3 - 'carrierid', 'int', 'NULL', '', '', '', - 'upstream_rateid', 'int', 'NULL', '', '', '', ### #and now for our own fields ### - # a svcnum... right..? + 'cdrtypenum', 'int', 'NULL', '', '', '', + + 'charged_party', 'varchar', 'NULL', $char_d, '', '', + + # how it was rated internally... + 'rated_price', 'decimal', 'NULL', '10,4', '', '', + 'rated_seconds', 'int', 'NULL', '', '', '', + 'rated_minutes', 'double precision', 'NULL', '', '', '', + + 'carrierid', 'int', 'NULL', '', '', '', + + # service it was matched to 'svcnum', 'int', 'NULL', '', '', '', #NULL, done (or something) @@ -2963,6 +2988,8 @@ sub tables_hashref { 'acctid', 'bigint', '', '', '', '', 'termpart', 'int', '', '', '', '',#future use see below 'rated_price', 'decimal', 'NULL', '10,4', '', '', + 'rated_seconds', 'int', 'NULL', '', '', '', + 'rated_minutes', 'double precision', 'NULL', '', '', '', 'status', 'varchar', 'NULL', 32, '', '', 'svcnum', 'int', 'NULL', '', '', '', ], diff --git a/FS/FS/cdr.pm b/FS/FS/cdr.pm index 36721a81a..850f797bc 100644 --- a/FS/FS/cdr.pm +++ b/FS/FS/cdr.pm @@ -401,13 +401,15 @@ error, otherwise returns false. sub set_status_and_rated_price { my($self, $status, $rated_price, $svcnum, %opt) = @_; - if($opt{'inbound'}) { + + if ($opt{'inbound'}) { + my $term = qsearchs('cdr_termination', { acctid => $self->acctid, termpart => 1 # inbound }); my $error; - if($term) { + if ( $term ) { warn "replacing existing cdr status (".$self->acctid.")\n" if $term; $error = $term->delete; return $error if $error; @@ -419,13 +421,19 @@ sub set_status_and_rated_price { status => $status, svcnum => $svcnum, }); + $term->rated_seconds($opt{rated_seconds}) if exists($opt{rated_seconds}); + $term->rated_minutes($opt{rated_minutes}) if exists($opt{rated_minutes}); return $term->insert; - } - else { + + } else { + $self->freesidestatus($status); $self->rated_price($rated_price); + $self->rated_seconds($opt{rated_seconds}) if exists($opt{rated_seconds}); + $self->rated_minutes($opt{rated_minutes}) if exists($opt{rated_minutes}); $self->svcnum($svcnum) if $svcnum; return $self->replace(); + } } @@ -642,6 +650,20 @@ sub export_formats { return %export_formats; } +=item downstream_csv OPTION => VALUE ... + +Options: + +format + +charge + +seconds + +granularity + +=cut + sub downstream_csv { my( $self, %opt ) = @_; diff --git a/FS/FS/cust_bill_pkg.pm b/FS/FS/cust_bill_pkg.pm index c9b0a4dc1..52b29f2d3 100644 --- a/FS/FS/cust_bill_pkg.pm +++ b/FS/FS/cust_bill_pkg.pm @@ -820,11 +820,21 @@ sub usage { if ( $self->get('details') ) { @values = - map { $_->[2] } - grep { ref($_) && ( defined($classnum) ? $_->[3] eq $classnum : 1 ) } + map { ref($_) eq 'HASH' + ? $_->{'amount'} + : $_->[2] + } + grep { ref($_) && ( defined($classnum) + ? $classnum eq ( ref($_) eq 'HASH' + ? $_->{'classnum'} + : $_->[3] + ) + : 1 + ) + } @{ $self->get('details') }; - }else{ + } else { my $hashref = { 'billpkgnum' => $self->billpkgnum }; $hashref->{ 'classnum' } = $classnum if defined($classnum); @@ -852,11 +862,14 @@ sub usage_classes { my %seen = (); foreach my $detail ( grep { ref($_) } @{$self->get('details')} ) { - $seen{ $detail->[3] } = 1; + $seen{ ref($detail) eq 'HASH' + ? $detail->{'classnum'} + : $detail->[3] + } = 1; } keys %seen; - }else{ + } else { map { $_->classnum } qsearch({ table => 'cust_bill_pkg_detail', diff --git a/FS/FS/part_pkg/voip_inbound.pm b/FS/FS/part_pkg/voip_inbound.pm index e3af5aaf5..4714ccb28 100644 --- a/FS/FS/part_pkg/voip_inbound.pm +++ b/FS/FS/part_pkg/voip_inbound.pm @@ -1,16 +1,15 @@ package FS::part_pkg::voip_inbound; +use base qw( FS::part_pkg::recur_Common ); use strict; -use vars qw(@ISA $DEBUG %info); +use vars qw($DEBUG %info); use Date::Format; use Tie::IxHash; +use Text::CSV_XS; use FS::Conf; use FS::Record qw(qsearchs qsearch); -use FS::part_pkg::recur_Common; use FS::cdr; -use FS::part_pkg::recur_Common; - -@ISA = qw(FS::part_pkg::recur_Common); +use FS::rate_detail; $DEBUG = 0; @@ -56,10 +55,6 @@ tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities(); 'default' => '+1', }, - 'disable_tollfree' => { 'name' => 'Disable automatic toll-free processing', - 'type' => 'checkbox', - }, - 'use_amaflags' => { 'name' => 'Only charge for CDRs where the amaflags field is set to "2" ("BILL"/"BILLING").', 'type' => 'checkbox', }, @@ -148,7 +143,6 @@ tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities(); FS::part_pkg::prorate_Mixin::fieldorder, qw( min_charge min_included sec_granularity default_prefix - disable_tollfree use_amaflags use_carrierid use_cdrtypenum ignore_cdrtypenum @@ -160,7 +154,7 @@ tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities(); bill_every_call ) ], - 'weight' => 40, + 'weight' => 42, ); sub price_info { @@ -205,25 +199,21 @@ sub calc_usage { my $spool_cdr = $cust_pkg->cust_main->spool_cdr; - my $included_min = ($self->option('min_included') - && $self->option('min_included') > 0) - ? $self->option('min_included') : 0; my $charges = 0; # my $downstream_cdr = ''; - my $disable_tollfree = $self->option('disable_tollfree'); - my $ignore_unrateable = $self->option('ignore_unrateable', 'Hush!'); - my $use_duration = $self->option('use_duration'); - - my $output_format = $self->option('output_format', 'Hush!') || 'default'; + my $included_min = $self->option('min_included', 1) || 0; + my $use_duration = $self->option('use_duration'); + my $output_format = $self->option('output_format', 1) || 'default'; + my $granularity = length($self->option('sec_granularity')) + ? $self->option('sec_granularity') + : 60; #for check_chargable, so we don't keep looking up options inside the loop my %opt_cache = (); - eval "use Text::CSV_XS;"; - die $@ if $@; my $csv = new Text::CSV_XS; foreach my $cust_svc ( @@ -232,19 +222,25 @@ sub calc_usage { my $svc_phone = $cust_svc->svc_x; foreach my $cdr ( $svc_phone->get_cdrs( - 'for_update' => 1, - 'status' => '', # unprocessed only - 'default_prefix' => $self->option('default_prefix'), 'inbound' => 1, + 'default_prefix' => $self->option('default_prefix'), + 'status' => '', # unprocessed only + 'for_update' => 1, ) ) { + + my $reason = $self->check_chargable( $cdr, + 'option_cache' => \%opt_cache, + ); + if ( $reason ) { + warn "not charging for CDR ($reason)\n" if $DEBUG; + next; + } + if ( $DEBUG > 1 ) { warn "rating inbound CDR $cdr\n". join('', map { " $_ => ". $cdr->{$_}. "\n" } keys %$cdr ); } - my $granularity = length($self->option('sec_granularity')) - ? $self->option('sec_granularity') - : 60; my $seconds = $use_duration ? $cdr->duration : $cdr->billsec; @@ -287,7 +283,7 @@ sub calc_usage { $call_details[0], $charge, $cdr->calltypenum, #classnum - $self->phonenum, + '', #phonenum, $cdr->accountcode, $cdr->startdate, $seconds, @@ -353,8 +349,7 @@ sub check_chargable { return "carrierid != $opt{'use_carrierid'}" if length($opt{'use_carrierid'}) - && $cdr->carrierid ne $opt{'use_carrierid'} #ne otherwise 0 matches '' - && ! $flags{'da_rewrote'}; + && $cdr->carrierid ne $opt{'use_carrierid'}; #ne otherwise 0 matches '' return "cdrtypenum != $opt{'use_cdrtypenum'}" if length($opt{'use_cdrtypenum'}) diff --git a/FS/FS/part_pkg/voip_tiered.pm b/FS/FS/part_pkg/voip_tiered.pm new file mode 100644 index 000000000..29e60d499 --- /dev/null +++ b/FS/FS/part_pkg/voip_tiered.pm @@ -0,0 +1,258 @@ +package FS::part_pkg::voip_tiered; +use base qw( FS::part_pkg::voip_cdr ); + +use strict; +use vars qw( $DEBUG %info ); +use Tie::IxHash; +use Date::Format; +use Text::CSV_XS; +use FS::Conf; +use FS::Record qw(qsearchs); # qsearch); +use FS::cdr; +use FS::rate_tier; +use FS::rate_detail; + +use Data::Dumper; + +$DEBUG = 0; + +tie my %cdr_inout, 'Tie::IxHash', + 'outbound' => 'Outbound', + 'inbound' => 'Inbound', + 'outbound_inbound' => 'Outbound and Inbound', +; + +tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities(); + +%info = ( + 'name' => 'VoIP tiered rate pricing of CDRs', + 'shortname' => 'VoIP/telco CDR tiered rating', + 'inherit_fields' => [ 'voip_cdr', 'prorate_Mixin', 'global_Mixin' ], + 'fields' => { + 'tiernum' => { 'name' => 'Tier plan', + 'type' => 'select', + 'select_table' => 'rate_tier', + 'select_key' => 'tiernum', + 'select_label' => 'tiername', + }, + 'cdr_inout' => { 'name'=> 'Call direction when using phone number matching', + 'type'=> 'select', + 'select_options' => \%cdr_inout, + }, + 'min_included' => { 'name' => 'Minutes included', + }, + 'sec_granularity' => { 'name' => 'Granularity', + 'type' => 'select', + 'select_options' => \%granularity, + }, + 'rating_method' => { 'disabled' => 1 }, + 'ratenum' => { 'disabled' => 1 }, + 'intrastate_ratenum' => { 'disabled' => 1 }, + 'min_charge' => { 'disabled' => 1 }, + 'ignore_unrateable' => { 'disabled' => 1 }, + 'domestic_prefix' => { 'disabled' => 1 }, + 'international_prefix' => { 'disabled' => 1 }, + 'disable_tollfree' => { 'disabled' => 1 }, + 'noskip_src_length_accountcode_tollfree' => { 'disabled' => 1 }, + 'accountcode_tollfree_ratenum' => { 'disabled' => 1 }, + 'noskip_dst_length_accountcode_tollfree' => { 'disabled' => 1 }, + }, + 'fieldorder' => [qw( + recur_temporality + recur_method cutoff_day ), + FS::part_pkg::prorate_Mixin::fieldorder, + qw( + cdr_svc_method cdr_inout + tiernum + ) + ], + 'weight' => 44, +); + +sub calc_usage { + my $self = shift; + my($cust_pkg, $sdate, $details, $param ) = @_; + + #my $last_bill = $cust_pkg->last_bill; + my $last_bill = $cust_pkg->get('last_bill'); #->last_bill falls back to setup + + return 0 + if $self->recur_temporality eq 'preceding' + && ( $last_bill eq '' || $last_bill == 0 ); + + my $included_min = $self->option('min_included', 1) || 0; + my $cdr_svc_method = $self->option('cdr_svc_method',1)||'svc_phone.phonenum'; + my $cdr_inout = ($cdr_svc_method eq 'svc_phone.phonenum') + && $self->option('cdr_inout',1) + || 'outbound'; + my $use_duration = $self->option('use_duration'); + my $granularity = length($self->option('sec_granularity')) + ? $self->option('sec_granularity') + : 60; + + #for check_chargable, so we don't keep looking up options inside the loop + my %opt_cache = (); + + my($svc_table, $svc_field) = split('\.', $cdr_svc_method); + + my %options = ( + 'disable_src' => $self->option('disable_src'), + 'default_prefix' => $self->option('default_prefix'), + 'status' => '', + 'for_update' => 1, + ); # $last_bill, $$sdate ) + $options{'by_svcnum'} = 1 if $svc_field eq 'svcnum'; + + ### + # pass one: find the total minutes/calls and store the CDRs + ### + my $total = 0; + my @cdrs = (); + + my @cust_svc; + if( $self->option('bill_inactive_svcs',1) ) { + #XXX in this mode do we need to restrict the set of CDRs by date also? + @cust_svc = $cust_pkg->h_cust_svc($$sdate, $last_bill); + } else { + @cust_svc = $cust_pkg->cust_svc; + } + @cust_svc = grep { $_->part_svc->svcdb eq $svc_table } @cust_svc; + + foreach my $cust_svc (@cust_svc) { + + my $svc_x; + if( $self->option('bill_inactive_svcs',1) ) { + $svc_x = $cust_svc->h_svc_x($$sdate, $last_bill); + } + else { + $svc_x = $cust_svc->svc_x; + } + + foreach my $pass (split('_', $cdr_inout)) { + + $options{'inbound'} = ( $pass eq 'inbound' ); + + foreach my $cdr ( + $svc_x->get_cdrs( %options ) + ) { + if ( $DEBUG > 1 ) { + warn "rating CDR $cdr\n". + join('', map { " $_ => ". $cdr->{$_}. "\n" } keys %$cdr ); + } + + my $charge = ''; + my $seconds = ''; + + $seconds = $use_duration ? $cdr->duration : $cdr->billsec; + + $seconds += $granularity - ( $seconds % $granularity ) + if $seconds # don't granular-ize 0 billsec calls (bills them) + && $granularity # 0 is per call + && $seconds % $granularity; + my $minutes = $granularity ? ($seconds / 60) : 1; + + my $charge_min = $minutes; + + $included_min -= $minutes; + if ( $included_min > 0 ) { + $charge_min = 0; + } else { + $charge_min = 0 - $included_min; + $included_min = 0; + } + + $cdr->tmp_inout( $pass ); + $cdr->tmp_rated_seconds( $seconds ); + $cdr->tmp_rated_minutes( $charge_min ); + $cdr->tmp_svcnum( $cust_svc->svcnum ); + push @cdrs, $cdr; + $total += $charge_min; + + } # $cdr + + } # $pass + + } # $cust_svc + + ### + # pass two: find a tiered rate and do the rest + ### + + my $rate_tier = qsearchs('rate_tier', { tiernum=>$self->option('tiernum') } ) + or die "unknown tiernum ". $self->option('tiernum'); + my $rate_tier_detail = $rate_tier->rate_tier_detail( $total ) + or die "no base rate for tier? ($total)"; + my $min_charge = $rate_tier_detail->min_charge; + + my $output_format = $self->option('output_format', 'Hush!') || 'default'; + + my $csv = new Text::CSV_XS; + + my $charges = 0; + my @invoice_details_sort; + + foreach my $cdr (@cdrs) { + + my $charge_min = $cdr->tmp_rated_minutes; + + my $charge = sprintf('%.4f', ( $min_charge * $charge_min ) + + 0.0000000001 ); #so 1.00005 rounds to 1.0001 + + + if ( $charge > 0 ) { + $charges += $charge; + + my $detail = + $cdr->downstream_csv( 'format' => $output_format, + 'charge' => $charge, + 'seconds' => ($use_duration ? + $cdr->duration : + $cdr->billsec), + 'granularity' => $granularity, + ); + + my $call_details = + { format => 'C', + detail => $detail, + amount => $charge, + #classnum => $cdr->calltypenum, #classnum + #phonenum => $phonenum, #XXX need this to sort on them + accountcode => $cdr->accountcode, + startdate => $cdr->startdate, + duration => $cdr->tmp_rated_seconds, + }; + + #warn " adding details on charge to invoice: [ ". + # join(', ', @{$call_details} ). " ]" + # if ( $DEBUG && ref($call_details) ); + push @invoice_details_sort, [ $call_details, $cdr->calldate_unix ]; + } + + my $error = $cdr->set_status_and_rated_price( + 'done', + $charge, + $cdr->tmp_svcnum, + 'inbound' => ($cdr->tmp_inout eq 'inbound'), + 'rated_minutes' => $charge_min, + 'rated_seconds' => $cdr->tmp_rated_seconds, + ); + die $error if $error; + + + } + + my @sorted_invoice_details = sort { ${$a}[1] <=> ${$b}[1] } @invoice_details_sort; + foreach my $sorted_call_detail ( @sorted_invoice_details ) { + push @$details, ${$sorted_call_detail}[0]; + } + + unshift @$details, { format => 'C', + detail => FS::cdr::invoice_header($output_format), + } + if @$details; + + $charges; +} + +1; + diff --git a/FS/FS/rate_tier.pm b/FS/FS/rate_tier.pm new file mode 100644 index 000000000..1ca457d63 --- /dev/null +++ b/FS/FS/rate_tier.pm @@ -0,0 +1,153 @@ +package FS::rate_tier; +use base qw( FS::o2m_Common FS::Record ); + +use strict; +use FS::Record qw( qsearch qsearchs ); +use FS::rate_tier_detail; + +=head1 NAME + +FS::rate_tier - Object methods for rate_tier records + +=head1 SYNOPSIS + + use FS::rate_tier; + + $record = new FS::rate_tier \%hash; + $record = new FS::rate_tier { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::rate_tier object represents a set of rate tiers. FS::rate_tier inherits + from FS::Record. The following fields are currently supported: + +=over 4 + +=item tiernum + +primary key + +=item tiername + +tiername + + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new record. To add the record to 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 method. + +=cut + +# the new method can be inherited from FS::Record, if a table method is defined + +sub table { 'rate_tier'; } + +=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 + +# the check method should currently be supplied - FS::Record contains some +# data checking routines + +sub check { + my $self = shift; + + my $error = + $self->ut_numbern('tiernum') + || $self->ut_text('tiername') + ; + return $error if $error; + + $self->SUPER::check; +} + +=item rate_tier_detail QUANTITY + +=cut + +sub rate_tier_detail { + my $self = shift; + + if ( defined($_[0]) && length($_[0]) ) { + + my $quantity = shift; + + qsearchs({ + 'table' => 'rate_tier_detail', + 'hashref' => { 'tiernum' => $self->tiernum, + 'min_quan' => { op=>'<=', value=>$quantity }, + }, + 'order_by' => 'ORDER BY min_charge ASC LIMIT 1', + }); + + } else { + + qsearch({ + 'table' => 'rate_tier_detail', + 'hashref' => { 'tiernum' => $self->tiernum, }, + 'order_by' => 'ORDER BY min_quan ASC', + }); + + } + +} + +=back + +=head1 BUGS + +=head1 SEE ALSO + +L, schema.html from the base documentation. + +=cut + +1; + diff --git a/FS/FS/rate_tier_detail.pm b/FS/FS/rate_tier_detail.pm new file mode 100644 index 000000000..60896f489 --- /dev/null +++ b/FS/FS/rate_tier_detail.pm @@ -0,0 +1,139 @@ +package FS::rate_tier_detail; +use base qw( FS::Record ); + +use strict; +use FS::Record; # qw( qsearch qsearchs ); +use FS::rate_tier; + +=head1 NAME + +FS::rate_tier_detail - Object methods for rate_tier_detail records + +=head1 SYNOPSIS + + use FS::rate_tier_detail; + + $record = new FS::rate_tier_detail \%hash; + $record = new FS::rate_tier_detail { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::rate_tier_detail object represents rate tier pricing. +FS::rate_tier_detail inherits from FS::Record. The following fields are +currently supported: + +=over 4 + +=item tierdetailnum + +primary key + +=item tiernum + +tiernum + +=item min_quan + +min_quan + +=item min_charge + +min_charge + + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new record. To add the record to 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 method. + +=cut + +# the new method can be inherited from FS::Record, if a table method is defined + +sub table { 'rate_tier_detail'; } + +=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 + +# the check method should currently be supplied - FS::Record contains some +# data checking routines + +sub check { + my $self = shift; + + my $min_quan = $self->min_quan; + $min_quan =~ s/[ ,]//g; + $self->min_quan($min_quan); + + $self->min_quan(0) if $self->min_quan eq ''; + + my $error = + $self->ut_numbern('tierdetailnum') + || $self->ut_foreign_key('tiernum', 'rate_tier', 'tiernum') + || $self->ut_number('min_quan') + || $self->ut_textn('min_charge') #XXX money? but we use 4 decimal places + ; + return $error if $error; + + $self->SUPER::check; +} + +=back + +=head1 BUGS + +=head1 SEE ALSO + +L, schema.html from the base documentation. + +=cut + +1; + diff --git a/FS/MANIFEST b/FS/MANIFEST index 37f1a5896..e983ea207 100644 --- a/FS/MANIFEST +++ b/FS/MANIFEST @@ -616,3 +616,7 @@ FS/export_nas.pm t/export_nas.t FS/legacy_cust_bill.pm t/legacy_cust_bill.t +FS/rate_tier.pm +t/rate_tier.t +FS/rate_tier_detail.pm +t/rate_tier_detail.t diff --git a/FS/t/rate_tier.t b/FS/t/rate_tier.t new file mode 100644 index 000000000..d735bdbb8 --- /dev/null +++ b/FS/t/rate_tier.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::rate_tier; +$loaded=1; +print "ok 1\n"; diff --git a/FS/t/rate_tier_detail.t b/FS/t/rate_tier_detail.t new file mode 100644 index 000000000..eccd676ae --- /dev/null +++ b/FS/t/rate_tier_detail.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::rate_tier_detail; +$loaded=1; +print "ok 1\n"; diff --git a/httemplate/browse/rate_tier.html b/httemplate/browse/rate_tier.html new file mode 100644 index 000000000..d84080857 --- /dev/null +++ b/httemplate/browse/rate_tier.html @@ -0,0 +1,53 @@ +<% include( 'elements/browse.html', + 'title' => 'Tiering plans', + 'name_singular' => 'tiering plan', + 'menubar' => [ 'Add a new tier plan' => + $p.'edit/rate_tier.html', + ], + 'query' => { 'table' => 'rate_tier', }, + 'count_query' => 'SELECT COUNT(*) FROM rate_tier', + 'header' => [ 'Plan', 'Tiers', ], + 'fields' => [ 'tiername', + $details_sub, + ], + 'links' => [ $link, ], + #'disableable' => 1, + #'disabled_statuspos' => 1, + ) +%> +<%init> + +die "access denied" + unless $FS::CurrentUser::CurrentUser->access_right('Configuration'); + +my $conf = new FS::Conf; +my $money_char = $conf->config('money_char') || '$'; + +my $link = [ "${p}edit/rate_tier.html?", 'tiernum' ]; + +my $details_sub = sub { + my $rate_tier = shift; + + [ [ { 'data' => 'Minimum quantity', + 'align' => 'center', + }, + { 'data' => 'Charge per minute/call', + 'align' => 'center', + }, + ], + map { my $rate_tier_detail = $_; + [ + { 'data' => $rate_tier_detail->min_quan, + 'align' => 'right', + }, + { 'data' => $money_char. $rate_tier_detail->min_charge, + 'align' => 'right', + }, + ]; + } + $rate_tier->rate_tier_detail + ]; + +}; + + diff --git a/httemplate/edit/elements/edit.html b/httemplate/edit/elements/edit.html index 6db54fd48..4e896f239 100644 --- a/httemplate/edit/elements/edit.html +++ b/httemplate/edit/elements/edit.html @@ -549,7 +549,7 @@ Example: // only spawn if we're the last element... return if not - var field_regex = /(\d+)(_[a-z]+)?$/; + var field_regex = /(\d+)(_[a-z_]+)?$/; var match = field_regex.exec(what.name); if ( !match ) { alert(what.name + " didn't match for " + what); diff --git a/httemplate/edit/process/rate_tier.html b/httemplate/edit/process/rate_tier.html new file mode 100644 index 000000000..f29edbb50 --- /dev/null +++ b/httemplate/edit/process/rate_tier.html @@ -0,0 +1,15 @@ +<% include( 'elements/process.html', + 'table' => 'rate_tier', + 'viewall_dir' => 'browse', + 'process_o2m' => { + 'table' => 'rate_tier_detail', + 'fields' => [qw( min_quan min_charge )], + }, + ) +%> +<%init> + +die "access denied" + unless $FS::CurrentUser::CurrentUser->access_right('Configuration'); + + diff --git a/httemplate/edit/rate_tier.html b/httemplate/edit/rate_tier.html new file mode 100644 index 000000000..f9df4b5ce --- /dev/null +++ b/httemplate/edit/rate_tier.html @@ -0,0 +1,54 @@ +<% include( 'elements/edit.html', + 'table' => 'rate_tier', + 'name_singular' => 'tiering plan', + 'fields' => [ + 'tiername', + #{ field=>'disabled', type=>'checkbox', value=>'Y' }, + { 'field' => 'tierdetailnum', + 'type' => 'rate_tier_detail', + 'colspan' => 2, + 'o2m_table' => 'rate_tier_detail', + 'm2_label' => 'Tier', + 'm2_error_callback' => $m2_error_callback, + }, + ], + 'labels' => { 'tiernum' => 'Plan #', + 'tiername' => 'Tiering plan', + 'tierdetailnum' => 'Tier', + }, + 'viewall_dir' => 'browse', + ) +%> +<%init> + +die "access denied" + unless $FS::CurrentUser::CurrentUser->access_right('Configuration'); + +my $m2_error_callback = sub { + my($cgi, $object) = @_; + + #process_o2m fields in process/rate_tier.html + my @fields = qw( min_quan min_charge ); + my @gfields = ( '', map "_$_", @fields ); + + map { + if ( /^tierdetailnum(\d+)$/ ) { + my $num = $1; + if ( grep $cgi->param("tierdetailnum$num$_"), @gfields ) { + my $x = new FS::rate_tier_detail { + 'tierdetailnum' => scalar($cgi->param("tierdetailnum$num")), + map { $_ => scalar($cgi->param("tierdetailnum${num}_$_")) } + @fields, + }; + $x; + } else { + (); + } + } else { + (); + } + } + $cgi->param; +}; + + diff --git a/httemplate/elements/menu.html b/httemplate/elements/menu.html index 17c422963..6bfe712f8 100644 --- a/httemplate/elements/menu.html +++ b/httemplate/elements/menu.html @@ -25,7 +25,7 @@ % 'style' => 'color:#999999', % 'actionlabel' => emt('About'), % 'width' => 300, -% 'height' => 360, +% 'height' => 375, % 'color' => '#7e0079', % 'scrolling' => 'no', % ); @@ -473,6 +473,8 @@ tie my %config_billing_rates, 'Tie::IxHash', 'Usage classes' => [ $fsurl.'browse/usage_class.html', 'Usage classes define groups of usage for taxation.' ], 'Time periods' => [ $fsurl.'browse/rate_time.html', 'Time periods define days and hours for rate plans' ], 'Edit rates with Excel' => [ $fsurl.'misc/rate_edit_excel.html', 'Download and edit rates with Excel, then upload changes.' ], #"Edit with Excel" ? + 'separator' => '', #its a separator! + 'Tiering plans' => [ $fsurl.'browse/rate_tier.html', 'Rating tiers' ], ; tie my %config_billing, 'Tie::IxHash'; diff --git a/httemplate/elements/rate_tier_detail.html b/httemplate/elements/rate_tier_detail.html new file mode 100644 index 000000000..ef1f38b8a --- /dev/null +++ b/httemplate/elements/rate_tier_detail.html @@ -0,0 +1,66 @@ +% unless ( $opt{'js_only'} ) { + + + + + +% foreach my $field ( @fields ) { +% +% my $value = $rate_tier_detail->get($field); + + +% } + +
+ <% $field eq 'min_charge' ? $money_char : '' %> + " + <% $onchange %> + >
+ <% $label{$field} %> +
+ +% } +<%init> + +my( %opt ) = @_; + +my $conf = new FS::Conf; +my $money_char = $conf->config('money_char') || '$'; + +my $name = $opt{'element_name'} || $opt{'field'} || 'tierdetailnum'; +my $id = $opt{'id'} || 'tierdetailnum'; + +my $curr_value = $opt{'curr_value'} || $opt{'value'}; + +my $onchange = ''; +if ( $opt{'onchange'} ) { + $onchange = $opt{'onchange'}; + $onchange .= '(this)' unless $onchange =~ /\(\w*\);?$/; + $onchange =~ s/\(what\);/\(this\);/g; #ugh, terrible hack. all onchange + #callbacks should act the same + $onchange = 'onChange="'. $onchange. '"'; +} + +my $rate_tier_detail; +if ( $curr_value ) { + $rate_tier_detail = qsearchs('rate_tier_detail', { 'tierdetailnum' => $curr_value } ); +} else { + $rate_tier_detail = new FS::rate_tier_detail {}; +} + +my %size = ( 'title' => 12 ); + +tie my %label, 'Tie::IxHash', + 'min_quan' => 'Minimum quantity', + 'min_charge' => 'Charge per minute/call', +; + +my @fields = keys %label; + + diff --git a/httemplate/elements/tr-rate_tier_detail.html b/httemplate/elements/tr-rate_tier_detail.html new file mode 100644 index 000000000..7b6f26b57 --- /dev/null +++ b/httemplate/elements/tr-rate_tier_detail.html @@ -0,0 +1,24 @@ +% unless ( $opt{'js_only'} ) { + + <% include('tr-td-label.html', %opt) %> + > + +% } +% + <% include( '/elements/rate_tier_detail.html', %opt ) %> +% +% unless ( $opt{'js_only'} ) { + + + + +% } +<%init> + +my( %opt ) = @_; + +my $cell_style = $opt{'cell_style'} ? 'STYLE="'. $opt{'cell_style'}. '"' : ''; + +$opt{'label'} ||= 'Tier'; + + -- 2.11.0