From: Mark Wells Date: Thu, 28 Feb 2013 04:46:58 +0000 (-0800) Subject: per-package bundles of voice minutes, #5738 X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=commitdiff_plain;h=d04c981b5f72ffadabde71af68022059af0d52a5 per-package bundles of voice minutes, #5738 --- diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm index 38139e11b..775131e5a 100644 --- a/FS/FS/ClientAPI/MyAccount.pm +++ b/FS/FS/ClientAPI/MyAccount.pm @@ -1643,15 +1643,26 @@ sub list_svcs { } my @cust_svc = (); + my @cust_pkg_usage = (); #foreach my $cust_pkg ( $cust_main->ncancelled_pkgs ) { foreach my $cust_pkg ( $p->{'ncancelled'} ? $cust_main->ncancelled_pkgs : $cust_main->unsuspended_pkgs ) { next if $pkgnum && $cust_pkg->pkgnum != $pkgnum; push @cust_svc, @{[ $cust_pkg->cust_svc ]}; #@{[ ]} to force array context + push @cust_pkg_usage, $cust_pkg->cust_pkg_usage; } @cust_svc = grep { $_->part_svc->selfservice_access ne 'hidden' } @cust_svc; + my %usage_pools; + foreach (@cust_pkg_usage) { + my $part = $_->part_pkg_usage; + my $tag = $part->description . ($part->shared ? 1 : 0); + my $row = $usage_pools{$tag} + ||= [ $part->description, 0, 0, $part->shared ? 1 : 0 ]; + $row->[1] += $_->minutes; # minutes remaining + $row->[2] += $part->minutes; # minutes total + } if ( $p->{'svcdb'} ) { my $svcdb = ref($p->{'svcdb'}) eq 'HASH' @@ -1761,6 +1772,11 @@ sub list_svcs { } @cust_svc ], + 'usage_pools' => [ + map { $usage_pools{$_} } + sort { $a cmp $b } + keys %usage_pools + ], }; } diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index 4b56eae61..e8747f28a 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -5347,6 +5347,19 @@ and customer address. Include units.', $cdr_type ? $cdr_type->cdrtypename : ''; }, }, + + { + 'key' => 'cdr-minutes_priority', + 'section' => 'telephony', + 'description' => 'Priority rule for assigning included minutes to CDRs.', + 'type' => 'select', + 'select_hash' => [ + '' => 'No specific order', + 'time' => 'Chronological', + 'rate_high' => 'Highest rate first', + 'rate_low' => 'Lowest rate first', + ], + }, { 'key' => 'brand-agent', diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm index 2bc1596f2..ae75539ac 100644 --- a/FS/FS/Mason.pm +++ b/FS/FS/Mason.pm @@ -332,6 +332,11 @@ if ( -e $addl_handler_use_file ) { use FS::GeocodeCache; use FS::log; use FS::log_context; + use FS::part_pkg_usage_class; + use FS::cust_pkg_usage; + use FS::part_pkg_usage_class; + use FS::part_pkg_usage; + use FS::cdr_cust_pkg_usage; # Sammath Naur if ( $FS::Mason::addl_handler_use ) { diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index eff4878fd..717e49844 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -1815,6 +1815,30 @@ sub tables_hashref { 'index' => [ [ 'pkgnum' ], [ 'discountnum' ], [ 'usernum' ], ], }, + 'cust_pkg_usage' => { + 'columns' => [ + 'pkgusagenum', 'serial', '', '', '', '', + 'pkgnum', 'int', '', '', '', '', + 'minutes', 'int', '', '', '', '', + 'pkgusagepart', 'int', '', '', '', '', + ], + 'primary_key' => 'pkgusagenum', + 'unique' => [], + 'index' => [ [ 'pkgnum' ], [ 'pkgusagepart' ] ], + }, + + 'cdr_cust_pkg_usage' => { + 'columns' => [ + 'cdrusagenum', 'bigserial', '', '', '', '', + 'acctid', 'bigint', '', '', '', '', + 'pkgusagenum', 'int', '', '', '', '', + 'minutes', 'int', '', '', '', '', + ], + 'primary_key' => 'cdrusagenum', + 'unique' => [], + 'index' => [ [ 'pkgusagenum' ], [ 'acctid' ] ], + }, + 'cust_bill_pkg_discount' => { 'columns' => [ 'billpkgdiscountnum', 'serial', '', '', '', '', @@ -3021,6 +3045,32 @@ sub tables_hashref { 'index' => [ [ 'disabled' ] ], }, + 'part_pkg_usage' => { + 'columns' => [ + 'pkgusagepart', 'serial', '', '', '', '', + 'pkgpart', 'int', '', '', '', '', + 'minutes', 'int', '', '', '', '', + 'priority', 'int', 'NULL', '', '', '', + 'shared', 'char', 'NULL', 1, '', '', + 'rollover', 'char', 'NULL', 1, '', '', + 'description', 'varchar', 'NULL', $char_d, '', '', + ], + 'primary_key' => 'pkgusagepart', + 'unique' => [], + 'index' => [ [ 'pkgpart' ] ], + }, + + 'part_pkg_usage_class' => { + 'columns' => [ + 'num', 'serial', '', '', '', '', + 'pkgusagepart', 'int', '', '', '', '', + 'classnum', 'int','NULL', '', '', '', + ], + 'primary_key' => 'num', + 'unique' => [ [ 'pkgusagepart', 'classnum' ] ], + 'index' => [], + }, + 'rate' => { 'columns' => [ 'ratenum', 'serial', '', '', '', '', diff --git a/FS/FS/cdr.pm b/FS/FS/cdr.pm index 5e986ab50..9a3114442 100644 --- a/FS/FS/cdr.pm +++ b/FS/FS/cdr.pm @@ -426,12 +426,25 @@ sub set_charged_party { Sets the status to the provided string. If there is an error, returns the error, otherwise returns false. +If status is being changed from 'rated' to some other status, also removes +any usage allocations to this CDR. + =cut sub set_status { my($self, $status) = @_; + my $old_status = $self->freesidestatus; $self->freesidestatus($status); - $self->replace; + my $error = $self->replace; + if ( $old_status eq 'rated' and $status ne 'done' ) { + # deallocate any usage + foreach (qsearch('cdr_cust_pkg_usage', {acctid => $self->acctid})) { + my $cust_pkg_usage = $_->cust_pkg_usage; + $cust_pkg_usage->set('minutes', $cust_pkg_usage->minutes + $_->minutes); + $error ||= $cust_pkg_usage->replace || $_->delete; + } + } + $error; } =item set_status_and_rated_price STATUS RATED_PRICE [ SVCNUM [ OPTION => VALUE ... ] ] @@ -578,7 +591,7 @@ reference of the number of included minutes and will be decremented by the rated minutes of this CDR. region_group_included_minutes_hashref is required for prefix price plans which -have included minues (otehrwise unused/ignored). It should be set to an empty +have included minues (otherwise unused/ignored). It should be set to an empty hashref at the start of a month's rating and then preserved across CDRs. =cut @@ -603,6 +616,7 @@ our %interval_cache = (); # for timed rates sub rate_prefix { my( $self, %opt ) = @_; my $part_pkg = $opt{'part_pkg'} or return "No part_pkg specified"; + my $cust_pkg = $opt{'cust_pkg'}; my $da_rewrote = 0; # this will result in those CDRs being marked as done... is that @@ -828,11 +842,6 @@ sub rate_prefix { $seconds_left -= $charge_sec; - my $included_min = $opt{'region_group_included_min_hashref'} || {}; - - $included_min->{$regionnum}{$ratetimenum} = $rate_detail->min_included - unless exists $included_min->{$regionnum}{$ratetimenum}; - my $granularity = $rate_detail->sec_granularity; my $minutes; @@ -850,20 +859,40 @@ sub rate_prefix { $seconds += $charge_sec; + if ( $rate_detail->min_included ) { + # the old, kind of deprecated way to do this + my $included_min = $opt{'region_group_included_min_hashref'} || {}; - my $region_group = ($part_pkg->option_cacheable('min_included') || 0) > 0; + # by default, set the included minutes for this region/time to + # what's in the rate_detail + $included_min->{$regionnum}{$ratetimenum} = $rate_detail->min_included + unless exists $included_min->{$regionnum}{$ratetimenum}; - ${$opt{region_group_included_min}} -= $minutes - if $region_group && $rate_detail->region_group; + # the way that doesn't work + #my $region_group = ($part_pkg->option_cacheable('min_included') || 0) > 0; + + #${$opt{region_group_included_min}} -= $minutes + # if $region_group && $rate_detail->region_group; + + if ( $included_min->{$regionnum}{$ratetimenum} > $minutes ) { + $charge_sec = 0; + $included_min->{$regionnum}{$ratetimenum} -= $minutes; + } else { + $charge_sec -= ($included_min->{$regionnum}{$ratetimenum} * 60); + $included_min->{$regionnum}{$ratetimenum} = 0; + } + } else { + # the new way! + my $applied_min = $cust_pkg->apply_usage( + 'cdr' => $self, + 'rate_detail' => $rate_detail, + 'minutes' => $minutes + ); + # for now, usage pools deal only in whole minutes + $charge_sec -= $applied_min * 60; + } - $included_min->{$regionnum}{$ratetimenum} -= $minutes; - if ( - $included_min->{$regionnum}{$ratetimenum} <= 0 - && ( ${$opt{region_group_included_min}} <= 0 - || ! $rate_detail->region_group - ) - ) - { + if ( $charge_sec > 0 ) { #NOW do connection charges here... right? #my $conn_seconds = min($seconds_left, $rate_detail->conn_sec); @@ -876,16 +905,9 @@ sub rate_prefix { } #should preserve (display?) this - my $charge_min = 0 - $included_min->{$regionnum}{$ratetimenum} - ( $conn_seconds / 60 ); - $included_min->{$regionnum}{$ratetimenum} = 0; + my $charge_min = ( $charge_sec - $conn_seconds ) / 60; $charge += ($rate_detail->min_charge * $charge_min) if $charge_min > 0; #still not rounded - } elsif ( ${$opt{region_group_included_min}} > 0 - && $region_group - && $rate_detail->region_group - ) - { - $included_min->{$regionnum}{$ratetimenum} = 0 } # choose next rate_detail diff --git a/FS/FS/cdr_cust_pkg_usage.pm b/FS/FS/cdr_cust_pkg_usage.pm new file mode 100644 index 000000000..6ef7f2dea --- /dev/null +++ b/FS/FS/cdr_cust_pkg_usage.pm @@ -0,0 +1,124 @@ +package FS::cdr_cust_pkg_usage; + +use strict; +use base qw( FS::Record ); +use FS::Record qw( qsearch qsearchs ); + +=head1 NAME + +FS::cdr_cust_pkg_usage - Object methods for cdr_cust_pkg_usage records + +=head1 SYNOPSIS + + use FS::cdr_cust_pkg_usage; + + $record = new FS::cdr_cust_pkg_usage \%hash; + $record = new FS::cdr_cust_pkg_usage { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::cdr_cust_pkg_usage object represents an allocation of included +usage minutes to a call. FS::cdr_cust_pkg_usage inherits from +FS::Record. The following fields are currently supported: + +=over 4 + +=item cdrusagenum - primary key + +=item acctid - foreign key to cdr.acctid + +=item pkgusagenum - foreign key to cust_pkg_usage.pkgusagenum + +=item minutes - the number of minutes allocated + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new example. To add the example 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 { 'cdr_cust_pkg_usage'; } + +=item insert + +Adds this record to the database. If there is an error, returns the error, +otherwise returns false. + +=item delete + +Delete this record from the database. + +=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. + +=item check + +Checks all fields to make sure this is a valid example. If there is +an error, returns the error, otherwise returns false. Called by the insert +and replace methods. + +=cut + +sub check { + my $self = shift; + + my $error = + $self->ut_numbern('cdrusagenum') + || $self->ut_foreign_key('acctid', 'cdr', 'acctid') + || $self->ut_foreign_key('pkgusagenum', 'cust_pkg_usage', 'pkgusagenum') + || $self->ut_number('minutes') + ; + return $error if $error; + + $self->SUPER::check; +} + +=item cust_pkg_usage + +Returns the L object that this usage allocation came from. + +=item cdr + +Returns the L object that the usage was applied to. + +=cut + +sub cust_pkg_usage { + FS::cust_pkg_usage->by_key($_[0]->pkgusagenum); +} + +sub cdr { + FS::cdr->by_key($_[0]->acctid); +} + +=back + +=head1 SEE ALSO + +L, schema.html from the base documentation. + +=cut + +1; + diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm index 9c3d16a79..55a55ee8d 100644 --- a/FS/FS/cust_pkg.pm +++ b/FS/FS/cust_pkg.pm @@ -6,7 +6,7 @@ use base qw( FS::otaker_Mixin FS::cust_main_Mixin FS::location_Mixin use vars qw($disable_agentcheck $DEBUG $me); use Carp qw(cluck); use Scalar::Util qw( blessed ); -use List::Util qw(max); +use List::Util qw(min max); use Tie::IxHash; use Time::Local qw( timelocal timelocal_nocheck ); use MIME::Entity; @@ -21,6 +21,8 @@ use FS::cust_location; use FS::pkg_svc; use FS::cust_bill_pkg; use FS::cust_pkg_detail; +use FS::cust_pkg_usage; +use FS::cdr_cust_pkg_usage; use FS::cust_event; use FS::h_cust_svc; use FS::reg_code; @@ -861,6 +863,14 @@ sub cancel { } } + foreach my $usage ( $self->cust_pkg_usage ) { + $error = $usage->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "deleting usage pools: $error"; + } + } + $dbh->commit or die $dbh->errstr if $oldAutoCommit; return '' if $date; #no errors @@ -1825,6 +1835,16 @@ sub change { $dbh->rollback if $oldAutoCommit; return "Error setting usage values: $error"; } + } else { + # if NOT changing pkgpart, transfer any usage pools over + foreach my $usage ($self->cust_pkg_usage) { + $usage->set('pkgnum', $cust_pkg->pkgnum); + $error = $usage->replace; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "Error transferring usage pools: $error"; + } + } } # Order any supplemental packages. @@ -3269,7 +3289,175 @@ sub cust_pkg_discount_active { grep { $_->status eq 'active' } $self->cust_pkg_discount; } -=back +=item cust_pkg_usage + +Returns a list of all voice usage counters attached to this package. + +=cut + +sub cust_pkg_usage { + my $self = shift; + qsearch('cust_pkg_usage', { pkgnum => $self->pkgnum }); +} + +=item apply_usage OPTIONS + +Takes the following options: +- cdr: a call detail record (L) +- rate_detail: the rate determined for this call (L) +- minutes: the maximum number of minutes to be charged + +Finds available usage minutes for a call of this class, and subtracts +up to that many minutes from the usage pool. If the usage pool is empty, +and the C global config option is set, minutes may +be taken from other calls as well. Either way, an allocation record will +be created (L) and this method will return the +number of minutes of usage applied to the call. + +=cut + +sub apply_usage { + my ($self, %opt) = @_; + my $cdr = $opt{cdr}; + my $rate_detail = $opt{rate_detail}; + my $minutes = $opt{minutes}; + my $classnum = $rate_detail->classnum; + my $pkgnum = $self->pkgnum; + my $custnum = $self->custnum; + + local $SIG{HUP} = 'IGNORE'; + local $SIG{INT} = 'IGNORE'; + local $SIG{QUIT} = 'IGNORE'; + local $SIG{TERM} = 'IGNORE'; + local $SIG{TSTP} = 'IGNORE'; + local $SIG{PIPE} = 'IGNORE'; + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + my $order = FS::Conf->new->config('cdr-minutes_priority'); + + my @usage_recs = qsearch({ + 'table' => 'cust_pkg_usage', + 'addl_from' => ' JOIN part_pkg_usage USING (pkgusagepart)'. + ' JOIN cust_pkg USING (pkgnum)'. + ' JOIN part_pkg_usage_class USING (pkgusagepart)', + 'select' => 'cust_pkg_usage.*', + 'extra_sql' => " WHERE ( cust_pkg.pkgnum = $pkgnum OR ". + " ( cust_pkg.custnum = $custnum AND ". + " part_pkg_usage.shared IS NOT NULL ) ) AND ". + " part_pkg_usage_class.classnum = $classnum AND ". + " cust_pkg_usage.minutes > 0", + 'order_by' => " ORDER BY priority ASC", + }); + + my $orig_minutes = $minutes; + my $error; + while (!$error and $minutes > 0 and @usage_recs) { + my $cust_pkg_usage = shift @usage_recs; + $cust_pkg_usage->select_for_update; + my $cdr_cust_pkg_usage = FS::cdr_cust_pkg_usage->new({ + pkgusagenum => $cust_pkg_usage->pkgusagenum, + acctid => $cdr->acctid, + minutes => min($cust_pkg_usage->minutes, $minutes), + }); + $cust_pkg_usage->set('minutes', + sprintf('%.0f', $cust_pkg_usage->minutes - $cdr_cust_pkg_usage->minutes) + ); + $error = $cust_pkg_usage->replace || $cdr_cust_pkg_usage->insert; + $minutes -= $cdr_cust_pkg_usage->minutes; + } + if ( $order and $minutes > 0 and !$error ) { + # then try to steal minutes from another call + my %search = ( + 'table' => 'cdr_cust_pkg_usage', + 'addl_from' => ' JOIN cust_pkg_usage USING (pkgusagenum)'. + ' JOIN part_pkg_usage USING (pkgusagepart)'. + ' JOIN cust_pkg USING (pkgnum)'. + ' JOIN part_pkg_usage_class USING (pkgusagepart)'. + ' JOIN cdr USING (acctid)', + 'select' => 'cdr_cust_pkg_usage.*', + 'extra_sql' => " WHERE cdr.freesidestatus = 'rated' AND ". + " ( cust_pkg.pkgnum = $pkgnum OR ". + " ( cust_pkg.custnum = $custnum AND ". + " part_pkg_usage.shared IS NOT NULL ) ) AND ". + " part_pkg_usage_class.classnum = $classnum", + 'order_by' => ' ORDER BY part_pkg_usage.priority ASC', + ); + if ( $order eq 'time' ) { + # find CDRs that are using minutes, but have a later startdate + # than this call + my $startdate = $cdr->startdate; + if ($startdate !~ /^\d+$/) { + die "bad cdr startdate '$startdate'"; + } + $search{'extra_sql'} .= " AND cdr.startdate > $startdate"; + # minimize needless reshuffling + $search{'order_by'} .= ', cdr.startdate DESC'; + } else { + # XXX may not work correctly with rate_time schedules. Could + # fix this by storing ratedetailnum in cdr_cust_pkg_usage, I + # think... + $search{'addl_from'} .= + ' JOIN rate_detail'. + ' ON (cdr.rated_ratedetailnum = rate_detail.ratedetailnum)'; + if ( $order eq 'rate_high' ) { + $search{'extra_sql'} .= ' AND rate_detail.min_charge < '. + $rate_detail->min_charge; + $search{'order_by'} .= ', rate_detail.min_charge ASC'; + } elsif ( $order eq 'rate_low' ) { + $search{'extra_sql'} .= ' AND rate_detail.min_charge > '. + $rate_detail->min_charge; + $search{'order_by'} .= ', rate_detail.min_charge DESC'; + } else { + # this should really never happen + die "invalid cdr-minutes_priority value '$order'\n"; + } + } + my @cdr_usage_recs = qsearch(\%search); + my %reproc_cdrs; + while (!$error and @cdr_usage_recs and $minutes > 0) { + my $cdr_cust_pkg_usage = shift @cdr_usage_recs; + my $cust_pkg_usage = $cdr_cust_pkg_usage->cust_pkg_usage; + my $old_cdr = $cdr_cust_pkg_usage->cdr; + $reproc_cdrs{$old_cdr->acctid} = $old_cdr; + $cdr_cust_pkg_usage->select_for_update; + $old_cdr->select_for_update; + $cust_pkg_usage->select_for_update; + # in case someone else stole the usage from this CDR + # while waiting for the lock... + next if $old_cdr->acctid != $cdr_cust_pkg_usage->acctid; + # steal the usage allocation and flag the old CDR for reprocessing + $cdr_cust_pkg_usage->set('acctid', $cdr->acctid); + # if the allocation is more minutes than we need, adjust it... + my $delta = $cdr_cust_pkg_usage->minutes - $minutes; + if ( $delta > 0 ) { + $cdr_cust_pkg_usage->set('minutes', $minutes); + $cust_pkg_usage->set('minutes', $cust_pkg_usage->minutes + $delta); + $error = $cust_pkg_usage->replace; + } + warn 'CDR '.$cdr->acctid . ' stealing allocation '.$cdr_cust_pkg_usage->cdrusagenum.' from CDR '.$old_cdr->acctid."\n"; + $error ||= $cdr_cust_pkg_usage->replace; + # deduct the stolen minutes + $minutes -= $cdr_cust_pkg_usage->minutes; + } + # after all minute-stealing is done, reset the affected CDRs + foreach (values %reproc_cdrs) { + $error ||= $_->set_status(''); + # XXX or should we just call $cdr->rate right here? + # it's not like we can create a loop this way, since the min_charge + # or call time has to go monotonically in one direction. + # we COULD get some very deep recursions going, though... + } + } # if $order and $minutes + if ( $error ) { + $dbh->rollback; + die "error applying included minutes\npkgnum ".$self->pkgnum.", class $classnum, acctid ".$cdr->acctid."\n$error\n" + } else { + $dbh->commit if $oldAutoCommit; + return $orig_minutes - $minutes; + } +} =item supplemental_pkgs @@ -3296,6 +3484,8 @@ sub main_pkg { return; } +=back + =head1 CLASS METHODS =over 4 diff --git a/FS/FS/cust_pkg_usage.pm b/FS/FS/cust_pkg_usage.pm new file mode 100644 index 000000000..0eefd7480 --- /dev/null +++ b/FS/FS/cust_pkg_usage.pm @@ -0,0 +1,163 @@ +package FS::cust_pkg_usage; + +use strict; +use base qw( FS::Record ); +use FS::cust_pkg; +use FS::part_pkg_usage; +use FS::Record qw( qsearch qsearchs ); + +=head1 NAME + +FS::cust_pkg_usage - Object methods for cust_pkg_usage records + +=head1 SYNOPSIS + + use FS::cust_pkg_usage; + + $record = new FS::cust_pkg_usage \%hash; + $record = new FS::cust_pkg_usage { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::cust_pkg_usage object represents a counter of remaining included +minutes on a voice-call package. FS::cust_pkg_usage inherits from +FS::Record. The following fields are currently supported: + +=over 4 + +=item pkgusagenum - primary key + +=item pkgnum - the package (L) containing the usage + +=item pkgusagepart - the usage stock definition (L). +This record in turn links to the call usage classes that are eligible to +use these minutes. + +=item minutes - the remaining minutes + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +# the new method can be inherited from FS::Record, if a table method is defined + +=cut + +sub table { 'cust_pkg_usage'; } + +=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 + +sub delete { + my $self = shift; + my $error = $self->reset || $self->SUPER::delete; +} + +=item reset + +Remove all allocations of this usage to CDRs. + +=cut + +sub reset { + my $self = shift; + my $error = ''; + foreach (qsearch('cdr_cust_pkg_usage', { pkgusagenum => $self->pkgusagenum })) + { + $error ||= $_->delete; + } + $error; +} + +=item replace OLD_RECORD + +Replaces the OLD_RECORD with this one in the database. If there is an error, +returns the error, otherwise returns false. + +=cut + +# the replace method can be inherited from FS::Record + +=item check + +Checks all fields to make sure this is a valid example. 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('pkgusagenum') + || $self->ut_foreign_key('pkgnum', 'cust_pkg', 'pkgnum') + || $self->ut_numbern('minutes') + || $self->ut_foreign_key('pkgusagepart', 'part_pkg_usage', 'pkgusagepart') + ; + return $error if $error; + + if ( $self->minutes eq '' ) { + $self->set(minutes => $self->part_pkg_usage->minutes); + } + + $self->SUPER::check; +} + +=item cust_pkg + +Return the L linked to this record. + +=item part_pkg_usage + +Return the L linked to this record. + +=cut + +sub cust_pkg { + my $self = shift; + FS::cust_pkg->by_key($self->pkgnum); +} + +sub part_pkg_usage { + my $self = shift; + FS::part_pkg_usage->by_key($self->pkgusagepart); +} + +=back + +=head1 SEE ALSO + +L, schema.html from the base documentation. + +=cut + +1; + diff --git a/FS/FS/part_pkg.pm b/FS/FS/part_pkg.pm index 1b887a2f0..856a693dd 100644 --- a/FS/FS/part_pkg.pm +++ b/FS/FS/part_pkg.pm @@ -21,6 +21,7 @@ use FS::part_pkg_taxoverride; use FS::part_pkg_taxproduct; use FS::part_pkg_link; use FS::part_pkg_discount; +use FS::part_pkg_usage; use FS::part_pkg_vendor; @ISA = qw( FS::m2m_Common FS::option_Common ); @@ -1397,6 +1398,18 @@ sub part_pkg_discount { qsearch('part_pkg_discount', { 'pkgpart' => $self->pkgpart }); } +=item part_pkg_usage + +Returns the voice usage pools (see L) defined for +this package. + +=cut + +sub part_pkg_usage { + my $self = shift; + qsearch('part_pkg_usage', { 'pkgpart' => $self->pkgpart }); +} + =item _rebless Reblesses the object into the FS::part_pkg::PLAN class (if available), where diff --git a/FS/FS/part_pkg/voip_cdr.pm b/FS/FS/part_pkg/voip_cdr.pm index 04098a897..67ddfb5e9 100644 --- a/FS/FS/part_pkg/voip_cdr.pm +++ b/FS/FS/part_pkg/voip_cdr.pm @@ -434,6 +434,7 @@ sub calc_usage { my $error = $cdr->rate( 'part_pkg' => $self, + 'cust_pkg' => $cust_pkg, 'svcnum' => $svc_x->svcnum, 'single_price_included_min' => \$included_min, 'region_group_included_min' => \$region_group_included_min, @@ -581,6 +582,41 @@ sub calc_units { $count; } +sub reset_usage { + my ($self, $cust_pkg, %opt) = @_; + my @part_pkg_usage = $self->part_pkg_usage or return ''; + warn " resetting usage minutes\n" if $opt{debug}; + my %cust_pkg_usage = map { $_->pkgusagepart, $_ } $cust_pkg->cust_pkg_usage; + foreach my $part_pkg_usage (@part_pkg_usage) { + my $part = $part_pkg_usage->pkgusagepart; + my $usage = $cust_pkg_usage{$part} || + FS::cust_pkg_usage->new({ + 'pkgnum' => $cust_pkg->pkgnum, + 'pkgusagepart' => $part, + 'minutes' => $part_pkg_usage->minutes, + }); + foreach my $cdr_usage ( + qsearch('cdr_cust_pkg_usage', {'cdrusagenum' => $usage->cdrusagenum}) + ) { + my $error = $cdr_usage->delete; + warn " error resetting CDR usage: $error\n"; + } + + if ( $usage->pkgusagenum ) { + if ( $part_pkg_usage->rollover ) { + $usage->set('minutes', $part_pkg_usage->minutes + $usage->minutes); + } else { + $usage->set('minutes', $part_pkg_usage->minutes); + } + my $error = $usage->replace; + warn " error resetting usage minutes: $error\n" if $error; + } else { + my $error = $usage->insert; + warn " error resetting usage minutes: $error\n" if $error; + } + } #foreach $part_pkg_usage +} + # tells whether cust_bill_pkg_detail should return a single line for # each phonenum sub sum_usage { diff --git a/FS/FS/part_pkg_usage.pm b/FS/FS/part_pkg_usage.pm new file mode 100644 index 000000000..99014d398 --- /dev/null +++ b/FS/FS/part_pkg_usage.pm @@ -0,0 +1,159 @@ +package FS::part_pkg_usage; + +use strict; +use base qw( FS::m2m_Common FS::Record ); +use FS::Record qw( qsearch qsearchs ); +use Scalar::Util qw(blessed); + +=head1 NAME + +FS::part_pkg_usage - Object methods for part_pkg_usage records + +=head1 SYNOPSIS + + use FS::part_pkg_usage; + + $record = new FS::part_pkg_usage \%hash; + $record = new FS::part_pkg_usage { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::part_pkg_usage object represents a stock of usage minutes (generally +for voice services) included in a package definition. FS::part_pkg_usage +inherits from FS::Record. The following fields are currently supported: + +=over 4 + +=item pkgusagepart - primary key + +=item pkgpart - the package definition (L) + +=item minutes - the number of minutes included per billing cycle + +=item priority - the relative order in which to use this stock of minutes. + +=item shared - 'Y' to allow these minutes to be shared with other packages +belonging to the same customer. Otherwise, only usage allocated to this +package will use this stock of minutes. + +=item rollover - 'Y' to allow unused minutes to carry over between billing +cycles. Otherwise, the available minutes will reset to the value of the +"minutes" field upon billing. + +=item description - a text description of this stock of minutes + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +=item insert CLASSES + +=item replace CLASSES + +CLASSES can be an array or hash of usage classnums (see L) +to link to this record. + +=item delete + +=cut + +sub table { 'part_pkg_usage'; } + +sub insert { + my $self = shift; + my $opt = ref($_[0]) eq 'HASH' ? shift : { @_ }; + + $self->SUPER::insert + || $self->process_m2m( 'link_table' => 'part_pkg_usage_class', + 'target_table' => 'usage_class', + 'params' => $opt, + ); +} + +sub replace { + my $self = shift; + my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') ) + ? shift + : $self->replace_old; + my $opt = ref($_[0]) eq 'HASH' ? $_[0] : { @_ }; + $self->SUPER::replace($old) + || $self->process_m2m( 'link_table' => 'part_pkg_usage_class', + 'target_table' => 'usage_class', + 'params' => $opt, + ); +} + +sub delete { + my $self = shift; + $self->process_m2m( 'link_table' => 'part_pkg_usage_class', + 'target_table' => 'usage_class', + 'params' => {}, + ) || $self->SUPER::delete; +} + +=item check + +Checks all fields to make sure this is a valid example. If there is +an error, returns the error, otherwise returns false. Called by the insert +and replace methods. + +=cut + +sub check { + my $self = shift; + + my $error = + $self->ut_numbern('pkgusagepart') + || $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart') + || $self->ut_number('minutes') + || $self->ut_numbern('priority') + || $self->ut_flag('shared') + || $self->ut_flag('rollover') + || $self->ut_textn('description') + ; + return $error if $error; + + $self->SUPER::check; +} + +=item classnums + +Returns the usage class numbers that are allowed to use minutes from this +pool. + +=cut + +sub classnums { + my $self = shift; + if (!$self->get('classnums')) { + my $classnums = [ + map { $_->classnum } + qsearch('part_pkg_usage_class', { 'pkgusagepart' => $self->pkgusagepart }) + ]; + $self->set('classnums', $classnums); + } + @{ $self->get('classnums') }; +} + +=back + +=head1 SEE ALSO + +L, schema.html from the base documentation. + +=cut + +1; + diff --git a/FS/FS/part_pkg_usage_class.pm b/FS/FS/part_pkg_usage_class.pm new file mode 100644 index 000000000..9a99783af --- /dev/null +++ b/FS/FS/part_pkg_usage_class.pm @@ -0,0 +1,125 @@ +package FS::part_pkg_usage_class; + +use strict; +use base qw( FS::Record ); +use FS::Record qw( qsearch qsearchs ); + +=head1 NAME + +FS::part_pkg_usage_class - Object methods for part_pkg_usage_class records + +=head1 SYNOPSIS + + use FS::part_pkg_usage_class; + + $record = new FS::part_pkg_usage_class \%hash; + $record = new FS::part_pkg_usage_class { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::part_pkg_usage_class object is a link between a package usage stock +(L) and a voice usage class (L. +FS::part_pkg_usage_class inherits from FS::Record. The following fields +are currently supported: + +=over 4 + +=item num - primary key + +=item pkgusagepart - L key + +=item classnum - L key. Set to null to allow this stock +to be used for calls that have no usage class. To avoid confusion, you +should only do this if you don't use usage classes on your system. + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new example. To add the example 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 { 'part_pkg_usage_class'; } + +=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('num') + || $self->ut_foreign_key('pkgusagepart', 'part_pkg_usage', 'pkgusagepart') + || $self->ut_foreign_keyn('classnum', 'usage_class', 'classnum') + ; + return $error if $error; + + $self->SUPER::check; +} + +=back + +=head1 BUGS + +The author forgot to customize this manpage. + +=head1 SEE ALSO + +L, schema.html from the base documentation. + +=cut + +1; + diff --git a/FS/MANIFEST b/FS/MANIFEST index 0214fe7bc..860f45fe2 100644 --- a/FS/MANIFEST +++ b/FS/MANIFEST @@ -677,3 +677,13 @@ FS/log.pm t/log.t FS/log_context.pm t/log_context.t +FS/part_pkg_usage_class.pm +t/part_pkg_usage_class.t +FS/cust_pkg_usage.pm +t/cust_pkg_usage.t +FS/part_pkg_usage_class.pm +t/part_pkg_usage_class.t +FS/part_pkg_usage.pm +t/part_pkg_usage.t +FS/cdr_cust_pkg_usage.pm +t/cdr_cust_pkg_usage.t diff --git a/FS/bin/freeside-cdrrated b/FS/bin/freeside-cdrrated index 131b56a7e..99ea67594 100644 --- a/FS/bin/freeside-cdrrated +++ b/FS/bin/freeside-cdrrated @@ -33,9 +33,11 @@ if ( @cdrtypenums ) { $extra_sql .= ' AND cdrtypenum IN ('. join(',', @cdrtypenums ). ')'; } -our %svcnum = (); -our %pkgpart = (); -our %part_pkg = (); +our %svcnum = (); # phonenum => svcnum +our %pkgnum = (); # phonenum => pkgnum +our %cust_pkg = (); # pkgnum => cust_pkg (NOT phonenum => cust_pkg!) +our %pkgpart = (); # phonenum => pkgpart +our %part_pkg = (); # phonenum => part_pkg #some false laziness w/freeside-cdrrewrited @@ -91,6 +93,9 @@ while (1) { next; } + $pkgnum{$number} = $cust_pkg->pkgnum; + $cust_pkg{$cust_pkg->pkgnum} ||= $cust_pkg; + #get the package, search through the part_pkg and linked for a voip_cdr def w/matching cdrtypenum (or no use_cdrtypenum) my @part_pkg = grep { $_->plan eq 'voip_cdr' @@ -126,10 +131,11 @@ while (1) { #} #XXX if $part_pkg->option('min_included') then we can't prerate this CDR - + my $error = $cdr->rate( 'part_pkg' => $part_pkg{ $pkgpart{$number} }, - 'svcnum' => $svcnum{ $number }, + 'cust_pkg' => $cust_pkg{ $pkgnum{$number} }, + 'svcnum' => $svcnum{$number}, ); if ( $error ) { #XXX ??? diff --git a/FS/t/cdr_cust_pkg_usage.t b/FS/t/cdr_cust_pkg_usage.t new file mode 100644 index 000000000..1e2060e96 --- /dev/null +++ b/FS/t/cdr_cust_pkg_usage.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::cdr_cust_pkg_usage; +$loaded=1; +print "ok 1\n"; diff --git a/FS/t/cust_pkg_usage.t b/FS/t/cust_pkg_usage.t new file mode 100644 index 000000000..23a7b299e --- /dev/null +++ b/FS/t/cust_pkg_usage.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::cust_pkg_usage; +$loaded=1; +print "ok 1\n"; diff --git a/FS/t/part_pkg_usage.t b/FS/t/part_pkg_usage.t new file mode 100644 index 000000000..ba5ccb6c8 --- /dev/null +++ b/FS/t/part_pkg_usage.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::part_pkg_usage; +$loaded=1; +print "ok 1\n"; diff --git a/FS/t/part_pkg_usage_class.t b/FS/t/part_pkg_usage_class.t new file mode 100644 index 000000000..e46ff0648 --- /dev/null +++ b/FS/t/part_pkg_usage_class.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::part_pkg_usage_class; +$loaded=1; +print "ok 1\n"; diff --git a/fs_selfservice/FS-SelfService/cgi/view_usage.html b/fs_selfservice/FS-SelfService/cgi/view_usage.html index f7076684e..35d128998 100644 --- a/fs_selfservice/FS-SelfService/cgi/view_usage.html +++ b/fs_selfservice/FS-SelfService/cgi/view_usage.html @@ -1,7 +1,20 @@ <%= $url = "$selfurl?session=$session_id;action="; - @svc_acct = grep { $_->{svcdb} eq 'svc_acct' } @svcs; - @svc_phone = grep { $_->{svcdb} eq 'svc_phone' } @svcs; - @svc_port = grep { $_->{svcdb} eq 'svc_port' } @svcs; + %by_pkg_label = (); # not used yet, but I'm sure it will be... + @svc_acct = (); + @svc_phone = (); + @svc_port = (); + + foreach (@svcs) { + $by_pkg_label{ $_->{pkg_label} } ||= []; + push @{ $by_pkg_label{ $_->{pkg_label} } }, $_; + if ( $_->{svcdb} eq 'svc_acct' ) { + push @svc_acct, $_; + } elsif ( $_->{svcdb} eq 'svc_phone' ) { + push @svc_phone, $_; + } elsif ( $_->{svcdb} eq 'svc_port' ) { + push @svc_port, $_; + } + } ''; %> <%= include('header', 'Account usage') %> @@ -67,7 +80,7 @@ $any{$dir} = grep { $_->{$dir} } @svc_phone; } $OUT.= 'Call usage

- +
'; if ( $any{outbound} ) { @@ -110,7 +123,42 @@ ''; %> -<%= scalar(@svc_phone) ? '
Number


' : '' %> +<%= if ( @usage_pools ) { + $OUT .= ' + + + '; + my $any_shared = 0; + foreach my $usage (@usage_pools) { + # false laziness with the back office side + my ($description, $remain, $total, $shared) = @$usage; + if ( $shared ) { + $any_shared = 1; + $description .= '*'; + } + my $ratio = 255 * ($remain/$total); + $ratio = 255 if $color > 255; + my $color = + sprintf('STYLE="font-weight: bold; color: #%02x%02x00"', + 255 - $ratio, $ratio); + $OUT .= + qq! + + + + + !; + } + if ( $any_shared ) { + $OUT .= ''. + ''; + } +} +if ( scalar(@svc_phone) or scalar(@usage_pools) ) { + $OUT .= '
Remaining minutes
$description$remain / $total
* shared among all your phone plans


'; +} +''; +%> <%= if ( @svc_port ) { $OUT.= 'Bandwidth Graphs

diff --git a/httemplate/browse/part_pkg.cgi b/httemplate/browse/part_pkg.cgi index 5dee5b8d1..bb5bc5215 100755 --- a/httemplate/browse/part_pkg.cgi +++ b/httemplate/browse/part_pkg.cgi @@ -1,5 +1,6 @@ <% include( 'elements/browse.html', 'title' => 'Package Definitions', + 'menubar' => \@menubar, 'html_init' => $html_init, 'html_form' => $html_form, 'html_posttotal' => $html_posttotal, @@ -517,6 +518,8 @@ push @fields, sub { my $part_pkg = shift; + my @part_pkg_usage = sort { $a->priority <=> $b->priority } + $part_pkg->part_pkg_usage; [ (map { @@ -559,7 +562,27 @@ push @fields, ] } $part_pkg->svc_part_pkg_link - ) + ), + ( scalar(@part_pkg_usage) ? + [ { data => 'Usage minutes', + align => 'center', + colspan => 2, + data_style => 'b', + link => $p.'browse/part_pkg_usage.html#pkgpart'. + $part_pkg->pkgpart + } ] + : () + ), + ( map { + [ { data => $_->minutes, + align => 'right' + }, + { data => $_->description, + align => 'left' + }, + ] + } @part_pkg_usage + ), ]; }; @@ -590,4 +613,9 @@ if ( $acl_edit_bulk ) { ) . ''; } +my @menubar; +# show this if there are any voip_cdr packages defined +if ( FS::part_pkg->count("plan = 'voip_cdr'") ) { + push @menubar, 'Per-package usage minutes' => $p.'browse/part_pkg_usage.html'; +} diff --git a/httemplate/browse/part_pkg_usage.html b/httemplate/browse/part_pkg_usage.html new file mode 100644 index 000000000..209fd3a01 --- /dev/null +++ b/httemplate/browse/part_pkg_usage.html @@ -0,0 +1,112 @@ +<& /elements/header.html, 'Package usage minutes' &> +<& /elements/menubar.html, 'Package definitions', $p.'browse/part_pkg.cgi' &> + +
+ + + + + + + +% foreach my $class (@usage_class) { + +% } + + +% my $error = $cgi->param('error'); +% foreach my $part_pkg (@part_pkg) { +% my $pkgpart = $part_pkg->pkgpart; +% my @part_pkg_usage; +% if ( $error ) { +% @part_pkg_usage = @{ $error->{$pkgpart} }; +% } else { +% @part_pkg_usage = $part_pkg->part_pkg_usage; +% foreach my $usage (@part_pkg_usage) { +% foreach ($usage->classnums) { +% $usage->set("class$_".'_', 'Y'); +% } +% } +% } + + +% # make it easy to enumerate the pkgparts later + + +% # template row + + +% foreach (qw(shared rollover)) { + +% } + + +% foreach (@usage_class) { +% my $classnum = 'class' . $_->classnum . '_'; + +% } + + <& /elements/auto-table.html, + table => "pkgpart$pkgpart", + template_row => "pkgpart$pkgpart".'_template', + data => \@part_pkg_usage, + &> +% } +
MinutesSharedRolloverDescriptionPriority<% $class->classname %>
><% $part_pkg->pkg_comment %>
+ + + + + + + + + + +
+
+ +
+<& /elements/footer.html &> +<%init> +my $curuser = $FS::CurrentUser::CurrentUser; +die "access denied" + unless $curuser->access_right( + ['Edit package definitions', 'Edit global package definitions'] + ); + +my @where = ("(plan = 'voip_cdr' OR plan = 'voip_inbound')", + "freq != '0'", + "disabled IS NULL"); +push @where, FS::part_pkg->curuser_pkgs_sql + unless $curuser->access_right('Edit global package definitions'); +my $extra_sql = ' WHERE '.join(' AND ', @where); +my @part_pkg = qsearch({ + 'table' => 'part_pkg', + 'extra_sql' => $extra_sql, + 'order_by' => ' ORDER BY pkgpart', +}); + +my @usage_class = sort { $a->weight <=> $b->weight } + qsearch('usage_class', { disabled => '' }); + +my $n_usage_classes = scalar(@usage_class); +my $n_cols = $n_usage_classes + 5; # minutes, shared, rollover, desc, prio + diff --git a/httemplate/edit/process/part_pkg_usage.html b/httemplate/edit/process/part_pkg_usage.html new file mode 100644 index 000000000..eb6c37b82 --- /dev/null +++ b/httemplate/edit/process/part_pkg_usage.html @@ -0,0 +1,67 @@ +% if ( $is_error ) { +% $cgi->param('error' => \%part_pkg_usage); +% # internal redirect, because it's a lot of state to pass through +<& /browse/part_pkg_usage.html &> +% } else { +% # uh, not quite sure... +<% $cgi->redirect($fsurl.'browse/part_pkg.cgi') %> +% } +<%init> +my %vars = $cgi->Vars; +my %part_pkg_usage; +my $is_error; +foreach my $pkgpart ($cgi->param('pkgpart')) { + next unless $pkgpart =~ /^\d+$/; + my $part_pkg = FS::part_pkg->by_key($pkgpart) + or die "unknown pkgpart $pkgpart"; + my %old = map { $_->pkgusagepart => $_ } $part_pkg->part_pkg_usage; + $part_pkg_usage{$pkgpart} ||= []; + my @rows; + foreach (grep /^pkgpart$pkgpart/, keys %vars) { + /^pkgpart\d+_(\w+\D)(\d+)$/ or die "misspelled field name '$_'"; + my $value = delete $vars{$_}; + my $field = $1; + my $row = $2; + $rows[$row] ||= {}; + $rows[$row]->{$field} = $value; + } + + foreach my $row (@rows) { + next if !defined($row); + my $error; + my %classes; + foreach my $class (grep /^class/, keys %$row) { + $class =~ /^class(\d+)_$/; + my $classnum = $1; + $classes{$classnum} = delete $row->{$class}; + } + my $usage = FS::part_pkg_usage->new($row); + $usage->set('pkgpart', $pkgpart); + if ( $usage->pkgusagepart and $row->{minutes} > 0 ) { + $error = $usage->replace(\%classes); + # and don't delete the existing one + delete($old{$usage->pkgusagepart}); + } elsif ( $row->{minutes} > 0 ) { + $error = $usage->insert(\%classes); + } else { + next; + } + if ( $error ) { + $usage->set('error', $error); + $is_error = 1; + } + push @{ $part_pkg_usage{$pkgpart} }, $usage; + } + + foreach my $usage (values %old) { + # all of these were not sent back by the client, so delete them + my $error = $usage->delete; + if ( $error ) { + $usage->set('error', $error); + $is_error = 1; + unshift @{ $part_pkg_usage{$pkgpart} }, $usage; + } + } + +} + diff --git a/httemplate/elements/auto-table.html b/httemplate/elements/auto-table.html index 9aff94e67..3a3bd405d 100644 --- a/httemplate/elements/auto-table.html +++ b/httemplate/elements/auto-table.html @@ -70,8 +70,8 @@ function <%$pre%>set_rownum(obj, rownum) { if ( obj.id ) { obj.id = obj.id + rownum; } - if ( obj.name ) { - obj.name = obj.name + rownum; + if ( obj.getAttribute('name') ) { + obj.setAttribute('name', obj.getAttribute('name') + rownum); // also, in this case it's a form field that will be part of the record // so set up an onchange handler obj.onchange = <%$pre%>possiblyAddRow_factory(obj); @@ -96,17 +96,32 @@ function <%$pre%>addRow(data) { <%$pre%>set_rownum(row, this_rownum); if(data instanceof Array) { for (i = 0; i < data.length && i < <%$pre%>fieldorder.length; i++) { - var el = document.getElementsByName(<%$pre%>fieldorder[i] + this_rownum)[0]; + var el = document.getElementsByName(<%$pre |js_string%> + + <%$pre%>fieldorder[i] + + this_rownum)[0]; if (el) { - el.value = data[i]; + if ( el.tagName.toLowerCase() == 'span' ) { + el.innerHTML = data[i]; + } else if ( el.type == 'checkbox' ) { + el.checked = (el.value == data[i]); + } else { + el.value = data[i]; + } } } } else if (data instanceof Object) { for (var field in data) { - var el = document.getElementsByName(field + this_rownum)[0]; + var el = document.getElementsByName(<%$pre |js_string%> + + field + + this_rownum)[0]; if (el) { - el.value = data[field]; -% # doesn't work for checkbox + if ( el.tagName.toLowerCase() == 'span' ) { + el.innerHTML = data[field]; + } else if ( el.type == 'checkbox' ) { + el.checked = (el.value == data[field]); + } else { + el.value = data[field]; + } } } } // else nothing @@ -123,6 +138,20 @@ function <%$pre%>deleteRow(rownum) { <%$pre%>tbody.removeChild(r); } +function <%$pre%>set_prefix(obj) { + if ( obj.id ) { + obj.id = <%$pre |js_string%> + obj.id; + } + if ( obj.getAttribute('name') ) { + obj.setAttribute('name', <%$pre |js_string%> + obj.getAttribute('name')); + } + for (var i = 0; i < obj.children.length; i++) { + if ( obj.children[i] instanceof Node ) { + <%$pre%>set_prefix(obj.children[i]); + } + } +} + function <%$pre%>init() { <%$pre%>template = document.getElementById(<% $template_row |js_string%>); <%$pre%>tbody = document.getElementById('<%$pre%>autotable'); @@ -131,8 +160,10 @@ function <%$pre%>init() { var table = <%$pre%>template.parentNode; table.removeChild(<%$pre%>template); // give it an id - <%$pre%>template.id = <%$pre |js_string%> + 'row'; - // and a magic identifier so we know it's been submitted + <%$pre%>template.id = 'row'; + // prefix the ids and names of the TR object and all its descendants + <%$pre%>set_prefix(<%$pre%>template); + // add a magic identifier so we know it's been submitted var magic = document.createElement('INPUT'); magic.setAttribute('type', 'hidden'); magic.setAttribute('name', '<%$pre%>magic'); @@ -140,14 +171,22 @@ function <%$pre%>init() { // and a delete button %# should this be enclosed in an actual