per-package bundles of voice minutes, #5738
authorMark Wells <mark@freeside.biz>
Thu, 28 Feb 2013 04:46:58 +0000 (20:46 -0800)
committerMark Wells <mark@freeside.biz>
Thu, 28 Feb 2013 04:46:58 +0000 (20:46 -0800)
25 files changed:
FS/FS/ClientAPI/MyAccount.pm
FS/FS/Conf.pm
FS/FS/Mason.pm
FS/FS/Schema.pm
FS/FS/cdr.pm
FS/FS/cdr_cust_pkg_usage.pm [new file with mode: 0644]
FS/FS/cust_pkg.pm
FS/FS/cust_pkg_usage.pm [new file with mode: 0644]
FS/FS/part_pkg.pm
FS/FS/part_pkg/voip_cdr.pm
FS/FS/part_pkg_usage.pm [new file with mode: 0644]
FS/FS/part_pkg_usage_class.pm [new file with mode: 0644]
FS/MANIFEST
FS/bin/freeside-cdrrated
FS/t/cdr_cust_pkg_usage.t [new file with mode: 0644]
FS/t/cust_pkg_usage.t [new file with mode: 0644]
FS/t/part_pkg_usage.t [new file with mode: 0644]
FS/t/part_pkg_usage_class.t [new file with mode: 0644]
fs_selfservice/FS-SelfService/cgi/view_usage.html
httemplate/browse/part_pkg.cgi
httemplate/browse/part_pkg_usage.html [new file with mode: 0644]
httemplate/edit/process/part_pkg_usage.html [new file with mode: 0644]
httemplate/elements/auto-table.html
httemplate/view/cust_main/packages.html
httemplate/view/cust_main/packages/package.html

index 38139e1..775131e 100644 (file)
@@ -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
+    ],
   };
 
 }
index 4b56eae..e8747f2 100644 (file)
@@ -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',
index 2bc1596..ae75539 100644 (file)
@@ -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 ) {
index eff4878..717e498 100644 (file)
@@ -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', '', '', '', '', 
index 5e986ab..9a31144 100644 (file)
@@ -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 (file)
index 0000000..6ef7f2d
--- /dev/null
@@ -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<hash> 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<FS::cust_pkg_usage> object that this usage allocation came from.
+
+=item cdr
+
+Returns the L<FS::cdr> 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<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
index 9c3d16a..55a55ee 100644 (file)
@@ -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<FS::cdr>)
+- rate_detail: the rate determined for this call (L<FS::rate_detail>)
+- 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<cdr-minutes_priority> global config option is set, minutes may
+be taken from other calls as well.  Either way, an allocation record will
+be created (L<FS::cdr_cust_pkg_usage>) 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 (file)
index 0000000..0eefd74
--- /dev/null
@@ -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<FS::cust_pkg>) containing the usage
+
+=item pkgusagepart - the usage stock definition (L<FS::part_pkg_usage>).
+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<FS::cust_pkg> linked to this record.
+
+=item part_pkg_usage
+
+Return the L<FS::part_pkg_usage> 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<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
index 1b887a2..856a693 100644 (file)
@@ -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<FS::part_pkg_usage>) 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
index 04098a8..67ddfb5 100644 (file)
@@ -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 (file)
index 0000000..99014d3
--- /dev/null
@@ -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<FS::part_pkg>)
+
+=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<FS::usage_class>)
+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<FS::Record>, 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 (file)
index 0000000..9a99783
--- /dev/null
@@ -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<FS::part_pkg_usage>) and a voice usage class (L<FS::usage_class)>.
+FS::part_pkg_usage_class inherits from FS::Record.  The following fields 
+are currently supported:
+
+=over 4
+
+=item num - primary key
+
+=item pkgusagepart - L<FS::part_pkg_usage> key
+
+=item classnum - L<FS::usage_class> 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<hash> 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<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
index 0214fe7..860f45f 100644 (file)
@@ -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
index 131b56a..99ea675 100644 (file)
@@ -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 (file)
index 0000000..1e2060e
--- /dev/null
@@ -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 (file)
index 0000000..23a7b29
--- /dev/null
@@ -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 (file)
index 0000000..ba5ccb6
--- /dev/null
@@ -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 (file)
index 0000000..e46ff06
--- /dev/null
@@ -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";
index f707668..35d1289 100644 (file)
@@ -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.= '<FONT SIZE="4">Call usage</FONT><BR><BR>
-              <TABLE BGCOLOR="#cccccc">
+              <TABLE BGCOLOR="#cccccc" STYLE="display:inline-block">
                 <TR>
                   <TH ALIGN="left">Number</TH>';
       if ( $any{outbound} ) {
 '';
 %>
 
-<%= scalar(@svc_phone) ? '</TABLE><BR><BR>' : '' %>
+<%= if ( @usage_pools ) {
+  $OUT .= '</TABLE>
+  <TABLE BGCOLOR="#cccccc" STYLE="display: inline-block">
+    <TR><TH COLSPAN=4>Remaining minutes</TH></TR>
+    ';
+  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!<TR>
+      <TD ALIGN="right">$description</TD>
+      <TD $color ALIGN="right">$remain</TD>
+      <TD $color> / </TD>
+      <TD $color>$total</TD>
+    </TR>!;
+  }
+  if ( $any_shared ) {
+    $OUT .= '<TR STYLE="font-size: 80%; font-style: italic">'.
+            '<TD COLSPAN=4>* shared among all your phone plans</TD></TR>';
+  }
+}
+if ( scalar(@svc_phone) or scalar(@usage_pools) ) {
+  $OUT .= '</TABLE><BR><BR>';
+}
+'';
+%>
 
 <%= if ( @svc_port ) {
       $OUT.= '<FONT SIZE="4">Bandwidth Graphs</FONT><BR><BR>
index 5dee5b8..bb5bc52 100755 (executable)
@@ -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 ) {
   ) . '</FORM>';
 }
 
+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';
+}
 </%init>
diff --git a/httemplate/browse/part_pkg_usage.html b/httemplate/browse/part_pkg_usage.html
new file mode 100644 (file)
index 0000000..209fd3a
--- /dev/null
@@ -0,0 +1,112 @@
+<& /elements/header.html, 'Package usage minutes' &>
+<& /elements/menubar.html, 'Package definitions', $p.'browse/part_pkg.cgi' &>
+<STYLE TYPE="text/css">
+.pkg_head {
+  background-color: #dddddd;
+  font-style: italic;
+}
+.pkg_head > td {
+  border-style: solid;
+  border-radius: 3px;
+  border-color: #555555;
+  border-width: 1px;
+}
+.usage > td {
+  text-align: center;
+}
+.error {
+  color: #ff0000;
+}
+</STYLE>
+<FORM METHOD="POST" ACTION="<%$fsurl%>edit/process/part_pkg_usage.html">
+  <TABLE STYLE="margin-top: 1em">
+    <TR>
+      <TH>Minutes</TH>
+      <TH>Shared</TH>
+      <TH>Rollover</TH>
+      <TH>Description</TH>
+      <TH>Priority</TH>
+%   foreach my $class (@usage_class) {
+      <TH><% $class->classname %></TH>
+%   }
+    </TR>
+
+% 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');
+%       }
+%     }
+%   }
+    <TR CLASS="pkg_head" ID="pkgpart<%$pkgpart%>">
+      <TD COLSPAN=<%$n_cols%>><% $part_pkg->pkg_comment %></TD>
+%   # make it easy to enumerate the pkgparts later
+      <INPUT TYPE="hidden" NAME="pkgpart" VALUE="<% $pkgpart %>">
+    </TR>
+%   # template row
+    <TR id="pkgpart<%$pkgpart%>_template" CLASS="usage">
+      <TD>
+        <INPUT TYPE="hidden" NAME="pkgusagepart">
+        <INPUT TYPE="text" NAME="minutes" ID="minutes" SIZE=7>
+      </TD>
+%     foreach (qw(shared rollover)) {
+      <TD>
+        <INPUT TYPE="checkbox" NAME="<% $_ %>" ID="<% $_ %>" VALUE="Y">
+      </TD>
+%     }
+      <TD>
+        <INPUT TYPE="text" NAME="description" ID="description" SIZE=20>
+      </TD>
+      <TD>
+        <INPUT TYPE="text" NAME="priority" ID="priority" SIZE=3>
+      </TD>
+%     foreach (@usage_class) {
+%       my $classnum = 'class' . $_->classnum . '_';
+      <TD>
+        <INPUT TYPE="checkbox" NAME="<% $classnum %>" ID="<% $classnum %>" VALUE="Y">
+      </TD>
+%     }
+    </TR>
+    <& /elements/auto-table.html,
+      table         => "pkgpart$pkgpart",
+      template_row  => "pkgpart$pkgpart".'_template',
+      data          => \@part_pkg_usage,
+    &>
+%   }
+  </TABLE>
+  <BR>
+  <INPUT TYPE="submit">
+</FORM>
+<& /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
+</%init>
diff --git a/httemplate/edit/process/part_pkg_usage.html b/httemplate/edit/process/part_pkg_usage.html
new file mode 100644 (file)
index 0000000..eb6c37b
--- /dev/null
@@ -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;
+    }
+  }
+
+}
+</%init>
index 9aff94e..3a3bd40 100644 (file)
@@ -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 <button> for aesthetics?
   var delete_button = document.createElement('IMG');
-  delete_button.id = 'delete_button';
+  delete_button.id = '<%$pre%>delete_button';
   delete_button.src = '<%$fsurl%>images/cross.png';
   delete_button.alt = 'X';
   // use an inline string for this so that it will be cloned properly
   delete_button.setAttribute('onclick', "<%$pre%>deleteRow(this.rownum);");
+  // and an error display
+  var error_span = document.createElement('SPAN');
+  error_span.className = 'error';
+  error_span.style.color = '#FF0000';
+  error_span.setAttribute('name', '<%$pre%>error');
+  error_span.style.padding = '5px';
   var delete_cell = document.createElement('TD');
+  delete_cell.style.textAlign = 'left';
   delete_cell.appendChild(delete_button);
   delete_cell.appendChild(magic); // it has to go somewhere
+  delete_cell.appendChild(error_span);
   <%$pre%>template.appendChild(delete_cell);
 
   // preload rows
index da4d587..24a12cc 100755 (executable)
@@ -11,6 +11,15 @@ table.package {
   border-spacing: 0;
   width: 100%;
 }
+table.usage {
+  border: 1px solid black;
+  margin: auto;
+  width: 60%;
+  border-spacing: 0px;
+}
+.shared > * {
+  background-color: #ffffaa;
+}
 .row0 { background-color: #eeeeee; }
 .row1 { background-color: #ffffff; }
 
index 3a362b6..d0fc182 100644 (file)
 %     }
 %   }
   </TABLE>
+% if ( @cust_pkg_usage ) {
+  <TABLE CLASS="usage inv">
+    <TR><TH COLSPAN=4><% mt('Included usage') %></TH></TR>
+%   foreach my $usage (@cust_pkg_usage) {
+%     my $part = $usage->part_pkg_usage;
+%     my $ratio = 255 * ($usage->minutes / $part->minutes);
+%     $ratio = 255 if $ratio > 255; # because rollover
+%     my $color = sprintf('STYLE="font-weight: bold; color: #%02x%02x00"', 255 - $ratio, $ratio);
+%     my $trstyle = '';
+%     $trstyle = ' CLASS="shared"' if $part->shared;
+    <TR<%$trstyle%>>
+      <TD ALIGN="right"><% $part->description %>: </TD>
+      <TD <%$color%> ALIGN="right"><% $usage->minutes %></TD>
+      <TD <%$color%>> / </TD>
+      <TD <%$color%>><% $part->minutes %></TD>
+%     if ( $part->shared ) {
+      <TD><I>(shared)</I></TD>
+%     }
+    </TR>
+%   }
+  </TABLE>
+% }
 
 </TD>
 
@@ -208,6 +230,17 @@ my $statedefault   = $opt{'statedefault'}
                      || ($countrydefault eq 'US' ? 'CA' : '');
 
 my $supplemental = $opt{'supplemental'} || 0;
+
+$cust_pkg->pkgnum =~ /^(\d+)$/;
+my $pkgnum = $1;
+my @cust_pkg_usage = qsearch({
+  'select'    => 'cust_pkg_usage.*',
+  'table'     => 'cust_pkg_usage',
+  'addl_from' => ' JOIN part_pkg_usage USING (pkgusagepart)',
+  'extra_sql' => " WHERE pkgnum = $1",
+  'order_by'  => ' ORDER BY priority ASC, description ASC',
+});
+
 #subroutines
 
 #false laziness w/status.html