voip_cdr call rating by day and time, RT#4763
authormark <mark>
Thu, 1 Jul 2010 01:53:50 +0000 (01:53 +0000)
committermark <mark>
Thu, 1 Jul 2010 01:53:50 +0000 (01:53 +0000)
17 files changed:
FS/FS/Mason.pm
FS/FS/Schema.pm
FS/FS/part_pkg/voip_cdr.pm
FS/FS/rate.pm
FS/FS/rate_detail.pm
FS/FS/rate_time.pm [new file with mode: 0644]
FS/FS/rate_time_interval.pm [new file with mode: 0644]
FS/t/rate_time.t [new file with mode: 0644]
FS/t/rate_time_interval.t [new file with mode: 0644]
httemplate/browse/rate.cgi
httemplate/browse/rate_detail.html
httemplate/browse/rate_time.html [new file with mode: 0644]
httemplate/edit/process/rate_time.cgi [new file with mode: 0644]
httemplate/edit/rate_detail.html
httemplate/edit/rate_time.cgi [new file with mode: 0644]
httemplate/elements/auto-table.html [new file with mode: 0644]
httemplate/misc/delete-rate_detail.html [new file with mode: 0755]

index 6bc2338..f1e5c7d 100644 (file)
@@ -244,6 +244,8 @@ if ( -e $addl_handler_use_file ) {
   use FS::cgp_rule_action;
   use FS::bill_batch;
   use FS::cust_bill_batch;
+  use FS::rate_time;
+  use FS::rate_time_interval;
   # Sammath Naur
 
   if ( $FS::Mason::addl_handler_use ) {
index 73392e8..f113146 100644 (file)
@@ -2229,6 +2229,7 @@ sub tables_hashref {
         'conn_sec',        'int',     '',     '', '0', '',
         'min_charge',      'decimal', '', '10,5', '', '', #@money_type, '', '', 
         'sec_granularity', 'int',     '',     '', '', '', 
+        'ratetimenum',     'int', 'NULL',     '', '', '',
         #time period (link to table of periods)?
         'classnum',        'int', 'NULL',     '', '', '', 
       ],
@@ -2260,6 +2261,28 @@ sub tables_hashref {
       'index'       => [ [ 'countrycode' ], [ 'npa' ], [ 'regionnum' ] ],
     },
 
+    'rate_time' => {
+      'columns' => [
+        'ratetimenum', 'serial',      '',      '', '', '',
+        'ratetimename',   'varchar',      '', $char_d, '', '',
+      ],
+      'primary_key' => 'ratetimenum',
+      'unique'      => [],
+      'index'       => [],
+    },
+
+    'rate_time_interval' => {
+      'columns' => [
+        'intervalnum', 'serial', '', '', '', '',
+        'stime',          'int', '', '', '', '',
+        'etime',          'int', '', '', '', '',
+        'ratetimenum',    'int', '', '', '', '',
+      ],
+      'primary_key' => 'intervalnum',
+      'unique'      => [],
+      'index'       => [],
+    },
+
     'usage_class' => {
       'columns' => [
         'classnum',    'serial',      '',      '', '', '', 
index 9e21ba2..0ccd835 100644 (file)
@@ -13,6 +13,8 @@ use FS::rate_prefix;
 use FS::rate_detail;
 use FS::part_pkg::recur_Common;
 
+use List::Util qw(first min);
+
 @ISA = qw(FS::part_pkg::recur_Common);
 
 $DEBUG = 1;
@@ -318,6 +320,8 @@ sub calc_usage {
     @dirass = split(',', $dirass);
   }
 
+  my %interval_cache = (); # for timed rates
+
   #for check_chargable, so we don't keep looking up options inside the loop
   my %opt_cache = ();
 
@@ -347,11 +351,16 @@ sub calc_usage {
 
       my $rate_detail;
       my( $rate_region, $regionnum );
+      my $rate;
       my $pretty_destnum;
       my $charge = '';
       my $seconds = '';
+      my $weektime = '';
       my $regionname = '';
       my $classnum = '';
+      my $countrycode;
+      my $number;
+
       my @call_details = ();
       if ( $rating_method eq 'prefix' ) {
 
@@ -378,7 +387,7 @@ sub calc_usage {
           # (or calling station id for toll free calls)
           ###
 
-          my( $to_or_from, $number );
+          my( $to_or_from );
           if ( $cdr->is_tollfree && ! $disable_tollfree )
           { #tollfree call
             $to_or_from = 'from';
@@ -398,7 +407,7 @@ sub calc_usage {
 #          $dest =~ s/\@(.*)$// and $siphost = $1; # @10.54.32.1, @sip.example.com
 
           #determine the country code
-          my $countrycode;
+          $countrycode = '';
           if (    $number =~ /^$intl(((\d)(\d))(\d))(\d+)$/
                || $number =~ /^\+(((\d)(\d))(\d))(\d+)$/
              )
@@ -427,11 +436,20 @@ sub calc_usage {
           #asterisks here causes inserting the detail to barf, so:
           $pretty_destnum =~ s/\*//g;
 
-          my $rate = qsearchs('rate', { 'ratenum' => $ratenum })
+          $rate = qsearchs('rate', { 'ratenum' => $ratenum })
             or die "ratenum $ratenum not found!";
 
+          my @ltime = localtime($cdr->startdate);
+          $weektime = $ltime[0] + 
+                      $ltime[1]*60 +   #minutes
+                      $ltime[2]*3600 + #hours
+                      $ltime[6]*86400; #days since sunday
+          # if there's no timed rate_detail for this time/region combination,
+          # dest_detail returns the default.  There may still be a timed rate 
+          # that applies after the starttime of the call, so be careful...
           $rate_detail = $rate->dest_detail({ 'countrycode' => $countrycode,
                                               'phonenum'    => $number,
+                                              'weektime'    => $weektime,
                                             });
 
           if ( $rate_detail ) {
@@ -443,6 +461,17 @@ sub calc_usage {
                  "and rate detail $rate_detail\n"
               if $DEBUG;
 
+            if ( !exists($interval_cache{$regionnum}) ) {
+              my @intervals = (
+                sort { $a->stime <=> $b->stime }
+                map { my $r = $_->rate_time; $r ? $r->intervals : () }
+                $rate->rate_detail
+              );
+              $interval_cache{$regionnum} = \@intervals;
+              warn "  cached ".scalar(@intervals)." interval(s)\n"
+                if $DEBUG;
+            }
+
           } elsif ( $ignore_unrateable ) {
 
             $rate_region = '';
@@ -557,60 +586,111 @@ sub calc_usage {
 
         unless ( @call_details || ( $charge ne '' && $charge == 0 ) ) {
 
-          $included_min{$regionnum} = $rate_detail->min_included
-            unless exists $included_min{$regionnum};
+          my $seconds_left = $use_duration ? $cdr->duration : $cdr->billsec;
+          # charge for the first (conn_sec) seconds
+          $seconds = min($seconds_left, $rate_detail->conn_sec);
+          $seconds_left -= $seconds; 
+          $weektime     += $seconds;
+          $charge = sprintf("%.02f", $rate_detail->conn_charge);
+
+          my $total_minutes = 0;
+          my $etime;
+          while($seconds_left) {
+            my $ratetimenum = $rate_detail->ratetimenum; # may be empty
+
+            # find the end of the current rate interval
+            if(@{ $interval_cache{$regionnum} } == 0) {
+              # There are no timed rates in this group, so just stay 
+              # in the default rate_detail for the entire duration.
+              $etime = 0;
+            } 
+            elsif($ratetimenum) {
+              # This is a timed rate, so go to the etime of this interval.
+              # If it's followed by another timed rate, the stime of that 
+              # interval should match the etime of this one.
+              my $interval = $rate_detail->rate_time->contains($weektime);
+              $etime = $interval->etime;
+            }
+            else {
+              # This is a default rate, so use the stime of the next 
+              # interval in the sequence.
+              my $next_int = first { $_->stime > $weektime } 
+                              @{ $interval_cache{$regionnum} };
+              if ($next_int) {
+                $etime = $next_int->stime;
+              }
+              else {
+                # weektime is near the end of the week, so decrement 
+                # it by a full week and use the stime of the first 
+                # interval.
+                $weektime -= (3600*24*7);
+                $etime = $interval_cache{$regionnum}->[0]->stime;
+              }
+            }
 
-          my $granularity = $rate_detail->sec_granularity;
+            my $charge_sec = min($seconds_left, $etime - $weektime);
 
-                      # length($cdr->billsec) ? $cdr->billsec : $cdr->duration;
-          $seconds = $use_duration ? $cdr->duration : $cdr->billsec;
+            $seconds_left -= $charge_sec;
+            $seconds += $charge_sec;
 
-          $seconds -= $rate_detail->conn_sec;
-          $seconds = 0 if $seconds < 0;
+            $included_min{$regionnum}{$ratetimenum} = $rate_detail->min_included
+              unless exists $included_min{$regionnum}{$ratetimenum};
 
-          $seconds += $granularity - ( $seconds % $granularity )
-            if $seconds      # don't granular-ize 0 billsec calls (bills them)
-            && $granularity; # 0 is per call
-          my $minutes = sprintf("%.1f", $seconds / 60);
-          $minutes =~ s/\.0$// if $granularity == 60;
+            my $granularity = $rate_detail->sec_granularity;
 
-          # per call rather than per minute
-          $minutes = 1 unless $granularity;
+            # should this be done in every rate interval?
+            $charge_sec += $granularity - ( $charge_sec % $granularity )
+              if $charge_sec   # don't granular-ize 0 billsec calls (bills them)
+              && $granularity; # 0 is per call
+            my $minutes = sprintf("%.1f", $charge_sec / 60);
+            $minutes =~ s/\.0$// if $granularity == 60;
 
-          $included_min{$regionnum} -= $minutes;
+            # per call rather than per minute
+            $minutes = 1 unless $granularity;
+            $seconds_left = 0 unless $granularity;
 
-          $charge = sprintf('%.2f', $rate_detail->conn_charge);
+            $included_min{$regionnum}{$ratetimenum} -= $minutes;
+            
+            if ( $included_min{$regionnum}{$ratetimenum} <= 0 ) {
+              my $charge_min = 0 - $included_min{$regionnum}{$ratetimenum}; #XXX should preserve
+                                                              #(display?) this
+              $included_min{$regionnum}{$ratetimenum} = 0;
+              $charge += sprintf('%.2f', ($rate_detail->min_charge * $charge_min)
+                                         + 0.00000001 ); #so 1.005 rounds to 1.01
+              $total_minutes += $minutes;
+            }
 
-          if ( $included_min{$regionnum} < 0 ) {
-            my $charge_min = 0 - $included_min{$regionnum}; #XXX should preserve
-                                                            #(display?) this
-            $included_min{$regionnum} = 0;
-            $charge += sprintf('%.2f', ($rate_detail->min_charge * $charge_min)
-                                       + 0.00000001 ); #so 1.005 rounds to 1.01
-            $charge = sprintf('%.2f', $charge);
-          }
-          warn "Incrementing \$charges by $charge.  Now $charges\n" if $DEBUG;
-          $charges += $charge;
+            # choose next rate_detail
+            $rate_detail = $rate->dest_detail({ 'countrycode' => $countrycode,
+                                                'phonenum'    => $number,
+                                                'weektime'    => $etime })
+                    if($seconds_left);
+            # we have now moved forward to $etime
+            $weektime = $etime;
 
+          } #while $seconds_left
           # this is why we need regionnum/rate_region....
           warn "  (rate region $rate_region)\n" if $DEBUG;
 
-          @call_details = (
-           $cdr->downstream_csv( 'format'         => $output_format,
-                                 'granularity'    => $granularity,
-                                 'minutes'        => $minutes,
-                                 'charge'         => $charge,
-                                 'pretty_dst'     => $pretty_destnum,
-                                 'dst_regionname' => $regionname,
-                               )
-          );
-
           $classnum = $rate_detail->classnum;
+          $charge = sprintf('%.2f', $charge);
 
-        }
+          @call_details = (
+            $cdr->downstream_csv( 'format'         => $output_format,
+                                  'granularity'    => $rate_detail->sec_granularity, 
+                                  'minutes'        => $total_minutes,
+                                  'charge'         => $charge,
+                                  'pretty_dst'     => $pretty_destnum,
+                                  'dst_regionname' => $regionname,
+                                )
+          );
+        } #if(there is a rate_detail)
 
         if ( $charge > 0 ) {
           #just use FS::cust_bill_pkg_detail objects?
+          warn "Incrementing \$charges by $charge.  Now $charges\n" if $DEBUG;
+          $charges += $charge;
           my $call_details;
           my $phonenum = $cust_svc->svc_x->phonenum;
 
index 793846a..f30e4c7 100644 (file)
@@ -279,16 +279,22 @@ Destination can be specified as an FS::rate_detail object or regionnum
 (see L<FS::rate_detail>), or as a hashref with two keys: I<countrycode>
 and I<phonenum>.
 
+An optional third key, I<weektime>, will return a timed rate (one with 
+a non-null I<ratetimenum>) if one exists for a call at that time.  If 
+no matching timed rate exists, the non-timed rate will be returned.
+
 =cut
 
 sub dest_detail {
   my $self = shift;
 
   my $regionnum;
+  my $weektime;
   if ( ref($_[0]) eq 'HASH' ) {
 
     my $countrycode = $_[0]->{'countrycode'};
     my $phonenum    = $_[0]->{'phonenum'};
+    $weektime       = $_[0]->{'weektime'};
 
     #find a rate prefix, first look at most specific, then fewer digits,
     # finally trying the country code only
@@ -314,9 +320,31 @@ sub dest_detail {
   } else {
     $regionnum = ref($_[0]) ? shift->regionnum : shift;
   }
-
-  qsearchs( 'rate_detail', { 'ratenum'        => $self->ratenum,
-                             'dest_regionnum' => $regionnum,     } );
+  
+  if(!defined($weektime)) {
+    return qsearchs( 'rate_detail', 
+                            { 'ratenum'        => $self->ratenum,
+                              'dest_regionnum' => $regionnum,
+                              'ratetimenum'    => '',
+                            } );
+  }
+  else {
+    my @details = grep { my $rate_time = $_->rate_time;
+                            $rate_time && $rate_time->contains($weektime) }
+                       qsearch( 'rate_detail',
+                                    { 'ratenum'        => $self->ratenum,
+                                      'dest_regionnum' => $regionnum, } );
+    if(!@details) {
+      # this may change at some point
+      return $self->dest_detail($regionnum);
+    }
+    elsif(@details == 1) {
+      return $details[0];
+    }
+    else {
+      die "overlapping rate_detail times (region $regionnum, time $weektime)\n";
+    }
+  }
 }
 
 =item rate_detail
index f6cdedf..7b90452 100644 (file)
@@ -5,6 +5,7 @@ use vars qw( @ISA $DEBUG $me );
 use FS::Record qw( qsearch qsearchs dbh );
 use FS::rate;
 use FS::rate_region;
+use FS::rate_time;
 use Tie::IxHash;
 
 @ISA = qw(FS::Record);
@@ -54,6 +55,8 @@ inherits from FS::Record.  The following fields are currently supported:
 
 =item classnum - usage class (see L<FS::usage_class>) if any for this rate
 
+=item ratetimenum - rating time period (see L<FS::rate_time) if any
+
 =back
 
 =head1 METHODS
@@ -194,6 +197,30 @@ sub dest_prefixes_short {
   $self->dest_region->prefixes_short;
 }
 
+=item rate_time
+
+Returns the L<FS::rate_time> object associated with this call 
+plan rate, if there is one.
+
+=cut
+
+sub rate_time {
+  my $self = shift;
+  $self->ratetimenum ? FS::rate_time->by_key($self->ratetimenum) : ();
+}
+
+=item rate_time_name
+
+Returns the I<ratetimename> field of the L<FS::rate_time> object
+associated with this rate plan.
+
+=cut
+
+sub rate_time_name {
+  my $self = shift;
+  $self->ratetimenum ? $self->rate_time->ratetimename : '(default)';
+}
+
 =item classname
 
 Returns the name of the usage class (see L<FS::usage_class>) associated with
diff --git a/FS/FS/rate_time.pm b/FS/FS/rate_time.pm
new file mode 100644 (file)
index 0000000..40cd23e
--- /dev/null
@@ -0,0 +1,168 @@
+package FS::rate_time;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+use FS::rate_time_interval;
+
+=head1 NAME
+
+FS::rate_time - Object methods for rate_time records
+
+=head1 SYNOPSIS
+
+  use FS::rate_time;
+
+  $record = new FS::rate_time \%hash;
+  $record = new FS::rate_time { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::rate_time object represents a time period for selection of CDR billing 
+rates.  FS::rate_time inherits from FS::Record.  The following fields are 
+currently supported:
+
+=over 4
+
+=item ratetimenum
+
+primary key
+
+=item ratetimename
+
+A label (like "Daytime" or "Weekend").
+
+=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 { 'rate_time'; }
+
+=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 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('ratetimenum')
+    || $self->ut_text('ratetimename')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=item intervals
+
+Return the L<FS::rate_time_interval> objects included in this rating period.
+
+=cut
+
+sub intervals {
+  my $self = shift;
+  return qsearch({ table    => 'rate_time_interval', 
+                   hashref  => { ratetimenum => $self->ratetimenum },
+                   order_by => 'ORDER BY stime ASC',
+  });
+}
+
+=item contains TIME
+
+Return the L<FS::rate_time_interval> object that contains the specified 
+time-of-week (in seconds from the start of Sunday).  The primary use of 
+this is to test whether that time falls within this rating period.
+
+=cut
+
+sub contains {
+  my $self = shift;
+  my $weektime = shift;
+  return qsearchs('rate_time_interval', { ratetimenum => $self->ratetimenum,
+                                          stime => { op    => '<=', 
+                                                     value => $weektime },
+                                          etime => { op    => '>',
+                                                     value => $weektime },
+                                        } );
+}
+
+=item description
+
+Returns a list of arrayrefs containing the starting and 
+ending times of each interval in this period, in a readable
+format.
+
+=cut
+
+sub description {
+  my $self = shift;
+  return map { [ $_->description ] } $self->intervals;
+}
+
+
+=back
+
+=head1 BUGS
+
+To be seen.
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/rate_time_interval.pm b/FS/FS/rate_time_interval.pm
new file mode 100644 (file)
index 0000000..37bc83b
--- /dev/null
@@ -0,0 +1,162 @@
+package FS::rate_time_interval;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+
+=head1 NAME
+
+FS::rate_time_interval - Object methods for rate_time_interval records
+
+=head1 SYNOPSIS
+
+  use FS::rate_time_interval;
+
+  $record = new FS::rate_time_interval \%hash;
+  $record = new FS::rate_time_interval { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::rate_time_interval object represents an interval of clock time during 
+the week, such as "Monday, 7 AM to 8 PM".  FS::rate_time_interval inherits 
+from FS::Record.  The following fields are currently supported:
+
+=over 4
+
+=item intervalnum
+
+primary key
+
+=item stime
+
+Start of the interval, in seconds from midnight on Sunday.
+
+=item etime
+
+End of the interval.
+
+=item ratetimenum
+
+A foreign key to an L<FS::rate_time> object representing the set of intervals 
+to which this belongs.
+
+
+=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 { 'rate_time_interval'; }
+
+=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 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('intervalnum')
+    || $self->ut_number('stime')
+    || $self->ut_number('etime')
+    || $self->ut_number('ratetimenum')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=item rate_time
+
+Returns the L<FS::rate_time> comprising this interval.
+
+=cut
+
+sub rate_time {
+  my $self = shift;
+  FS::rate_time->by_key($self->ratetimenum);
+}
+
+=item description
+
+Returns two strings containing stime and etime, formatted 
+"Day HH:MM:SS AM/PM".  Example: "Mon 5:00 AM".
+
+=cut
+
+my @days = qw(Sun Mon Tue Wed Thu Fri Sat);
+
+sub description {
+  my $self = shift;
+  return map { 
+            sprintf('%s %02d:%02d:%02d %s',
+            $days[int($_/86400) % 7],
+            int($_/3600) % 12,
+            int($_/60) % 60,
+            $_ % 60,
+            (($_/3600) % 24 < 12) ? 'AM' : 'PM' )
+       } ( $self->stime, $self->etime );
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::rate_time>, L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/t/rate_time.t b/FS/t/rate_time.t
new file mode 100644 (file)
index 0000000..ece37d1
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::rate_time;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/rate_time_interval.t b/FS/t/rate_time_interval.t
new file mode 100644 (file)
index 0000000..68f9a95
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::rate_time_interval;
+$loaded=1;
+print "ok 1\n";
index 02d670f..428158a 100644 (file)
@@ -2,6 +2,8 @@
               'title'       => 'Rate plans',
               'menubar'     => [ 'Regions and Prefixes' =>
                                    $p.'browse/rate_region.html',
+                                 'Time Periods' =>
+                                   $p.'browse/rate_time.html',
                                ],
               'html_init'   => $html_init,
               'name'        => 'rate plans',
index 3371926..faaec2c 100644 (file)
@@ -1,39 +1,53 @@
-<% include( 'elements/browse.html',
-     'title'          => $title,
-     'name_singular'  => 'rate',
-     'html_init'      => $html_init,
-     'menubar'        => [ 'Rate plans' => $p.'browse/rate.cgi' ],
-     'query'          => {
-                           'table'     => 'rate_detail',
-                           'addl_from' => $join,
-                           'hashref'   => { 'ratenum' => $ratenum },
-                           'extra_sql' => $where,
-                         },
-     'count_query'    => "SELECT COUNT(*) FROM rate_detail $join".
-                         " WHERE ratenum = $ratenum $where",
-     'header'         => [
-                           'Region',
-                           'Prefix(es)',
-                           'Included<BR>minutes',
-                           'Connection<BR>charge',
-                           'Charge per<BR>minute',
-                           'Granularity',
-                           'Usage class',
-                         ],
-     'fields'         => [
-                           'regionname',
-                           sub { shift->dest_region->prefixes_short },
-                           sub { shift->min_included. $edit_hint },
-                           $conn_charge_sub,
-                           sub { $money_char. shift->min_charge. $edit_hint },
-                           sub { $granularity{ shift->sec_granularity } },
-                           'classname',
-                         ],
-     'links'          => [ '', '', $edit_link,    $edit_link,    '', '' ],
-     'link_onclicks'  => [ '', '', $edit_onclick, $edit_onclick, '', '' ],
-     'align'          => 'llrrcc',
-   )
-%>
+<% include('/elements/init_overlib.html') %>
+<% include('/elements/header.html',$title) %>
+<% include('/elements/menubar.html',@menubar) %>
+<BR><BR>
+<% include('/elements/table-grid.html') %>
+<TR>
+% my $col = 0;
+% foreach (@header) {
+%   my $hlink = $hlinks[$col];
+  <TH CLASS   = "grid",
+      BGCOLOR = "#cccccc">
+  <% $hlink ? qq!<A HREF="$hlink">$_</A>! : $_ %>
+  </TH>
+%   $col++;
+% } #foreach @header
+</TR><TR>
+% my $row = 0;
+% foreach my $region (@rate_region) {
+%   $col = 0;
+%   foreach ($region->regionname, $region->prefixes_short) {
+  <TD>
+    <A HREF="<% $p.'edit/rate_region.cgi?'.$region->regionnum %>"><% $_ %></A>
+  </TD>
+%   }
+%   foreach my $rate_time (@rate_time, '') {
+  <TD>
+%     my $detail = $details[$row][$col];
+%     if($detail) {
+      <TABLE CLASS="inv" STYLE="border:none">
+      <TR><TD><% edit_link($detail) %><% $money_char.$detail->min_charge %>
+              <% $detail->sec_granularity ? ' / minute':' / call' %>
+      <% $edit_hint %></A>
+      </TD></TR>
+      <% granularity_detail($detail) %>
+      <% min_included_detail($detail) %>
+      <% conn_charge_detail($detail) %>
+      <TR><TD><% $rate_time ? delete_link($detail) : '' %></TD></TR>
+    </TABLE>
+%     }
+%     else { #!$detail
+    <% add_link($ratenum, $region, $rate_time) %>
+%     }
+%     $col++;
+  </TD>
+%   } # foreach @rate_time
+</TR>
+%   $row++;
+% }# foreach @rate_region
+<% include('/elements/footer.html') %>
+
 <%once>
 
 tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities();
@@ -42,31 +56,92 @@ tie my %conn_secs,   'Tie::IxHash', FS::rate_detail::conn_secs();
 my $conf = new FS::Conf;
 my $money_char = $conf->config('money_char') || '$';
 
-my $join =
-  ' JOIN rate_region ON ( rate_detail.dest_regionnum = rate_region.regionnum )';
+my @menubar = ( 'Rate plans' => $p.'browse/rate.cgi',
+                'Regions and Prefixes' => $p.'browse/rate_region.html',
+                'Time Periods' => $p.'browse/rate_time.html',
+                );
 
-my $edit_link = [ 'javascript:void(0);', sub { ''; } ];
+sub small {
+  '<FONT SIZE="-1">'.shift.'</FONT>'
+}
+my $edit_hint = small('(edit)');
 
-my $edit_onclick = sub {
+sub edit_link {
   my $rate_detail = shift;
   my $ratedetailnum = $rate_detail->ratedetailnum;
+  '<A HREF="javascript:void(0);" onclick="'.
   include( '/elements/popup_link_onclick.html',
              'action'      => "${p}edit/rate_detail.html?$ratedetailnum",
              'actionlabel' => 'Edit rate',
              'height'      => 420,
              #default# 'width'       => 540,
              #default# 'color'       => '#333399',
-         );
-};
-my $edit_hint = '&nbsp;<FONT SIZE="-1">(edit)</FONT>';
-
-my $conn_charge_sub = sub {
-   my $rate_detail = shift;
-   #return '' unless $rate_detail->conn_charge > 0 || $rate_detail->conn_sec;
-   $money_char. $rate_detail->conn_charge.
-     ($rate_detail->conn_sec ? ' for '.$conn_secs{$rate_detail->conn_sec} : '').
-     $edit_hint;
-};
+         ) . '">'
+}
+
+sub add_link {
+  my ($ratenum, $region, $rate_time) = @_;
+  '<A HREF="javascript:void(0);" onclick="'.
+  include( '/elements/popup_link_onclick.html',
+             'action'      => "${p}edit/rate_detail.html?ratenum=$ratenum".
+                               ';dest_regionnum='.$region->regionnum.
+                               ($rate_time ? 
+                                 ';ratetimenum='.$rate_time->ratetimenum :
+                                 ''),
+             'actionlabel' => 'Add rate',
+             'height'      => 420,
+             ).'">'.small('(add)').'</A>'
+}
+
+sub delete_link {
+  my $rate_detail = shift;
+  my $ratedetailnum = $rate_detail->ratedetailnum;
+  my $onclick = include( '/elements/popup_link_onclick.html',
+             'action'      => "${p}misc/delete-rate_detail.html?$ratedetailnum",
+             'actionlabel' => 'Delete rate',
+             'width'       => 510,
+             'height'      => 315,
+             'frame'       => 'top',
+             );
+  $onclick = "if(confirm('Delete this rate?')) { $onclick }";
+  qq!<A HREF="javascript:void(0);" onclick="$onclick">!.small('(delete)').'</A>'
+}
+
+sub granularity_detail {
+  my $rate_detail = shift;
+  if($rate_detail->sec_granularity != 60 && $rate_detail->sec_granularity > 0) {
+    '<TR><TD>'.
+    small('in '.$granularity{$rate_detail->sec_granularity}.' increments').
+    '</TD></TR>';
+  }
+  else { '' }
+}
+
+sub min_included_detail {
+  my $rate_detail = shift;
+  if($rate_detail->min_included) {
+    '<TR><TD>'.
+    small( $rate_detail->min_included . 
+            ($rate_detail->sec_granularity ? 
+             ' minutes included' : 
+             ' calls included') ).
+    '</TD></TR>'
+  }
+  else { '' }
+}
+
+sub conn_charge_detail {
+  my $rate_detail = shift;
+  if($rate_detail->conn_charge > 0) {
+  #return '' unless $rate_detail->conn_charge > 0 || $rate_detail->conn_sec;
+    '<TR><TD>'.
+    small( $money_char. $rate_detail->conn_charge.
+      ' for '.$conn_secs{$rate_detail->conn_sec}
+    ).
+    '</TD></TR>'
+  }
+  else { '' }
+}
 
 </%once>
 <%init>
@@ -74,8 +149,6 @@ my $conn_charge_sub = sub {
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
 
-my $html_init = include('/elements/init_overlib.html');
-
 $cgi->param('ratenum') =~ /^(\d+)$/ or die "unparsable ratenum";
 my $ratenum = $1;
 my $rate = qsearchs('rate', { 'ratenum' => $ratenum } )
@@ -83,11 +156,10 @@ my $rate = qsearchs('rate', { 'ratenum' => $ratenum } )
 my $ratename = $rate->ratename;
 my $title = "$ratename rates";
 
-my @where = ();
-
+my $where;
 if ( $cgi->param('countrycode') =~ /^(\d+)$/ ) { 
   my $countrycode = $1;
-  push @where, "0 < ( SELECT COUNT(*) FROM rate_prefix
+  $where = "WHERE 0 < ( SELECT COUNT(*) FROM rate_prefix
                         WHERE rate_prefix.regionnum = rate_region.regionnum
                           AND countrycode = '$countrycode'
                     )
@@ -95,6 +167,25 @@ if ( $cgi->param('countrycode') =~ /^(\d+)$/ ) {
   $title .= " for +$countrycode";
 }
 
-my $where = scalar(@where) ? ' AND '.join(' AND ', @where ) : '';
+my @rate_region = qsearch({ table     => 'rate_region',
+                            hashref   => {},
+                            extra_sql => $where,
+                          });
+
+my @rate_time = qsearch('rate_time', {});
+my @header = ('Region', 'Prefix(es)',
+  map( { $_->ratetimename } @rate_time ),
+  '(default)');
+my @hlinks = map {''} @header;
+
+my @rtns = ( map( { $_->ratetimenum } @rate_time ), '' );
+my @details;
+foreach my $region (@rate_region) {
+   push @details, [ map { qsearchs('rate_detail', 
+                                    { 'ratenum'        => $ratenum,
+                                      'dest_regionnum' => $region->regionnum,
+                                      'ratetimenum'    => $_ } ) or ''
+                        } @rtns ];
+}
 
 </%init>
diff --git a/httemplate/browse/rate_time.html b/httemplate/browse/rate_time.html
new file mode 100644 (file)
index 0000000..416ded4
--- /dev/null
@@ -0,0 +1,48 @@
+<% include( 'elements/browse.html',
+     'title'          => 'Rating Time Periods',
+     'name_singular'  => 'period',
+     'menubar'        => [ 'Rate plans' => $p.'browse/rate.cgi' ],
+     'html_init'      => $html_init,
+     'query'          => {
+                           'table'     => 'rate_time',
+                           'order_by'  => 'ratetimenum', # lacking anything else
+                           'hashref'   => {},
+                         },
+     'count_query'    => 'SELECT COUNT(*) FROM rate_time',
+     'header'         => \@header,
+     'fields'         => \@fields,
+     'links'          => \@links,
+     'align'          => \@align,
+   )
+%>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my $edit_url = $p.'edit/rate_time.cgi';
+
+my $link = [ "$edit_url?", 'ratetimenum' ];
+
+my $html_init =
+  'Time periods for VoIP and call billing.<BR><BR>'.
+  qq(<A HREF="$edit_url"><I>Add a new period</I></A><BR><BR>);
+
+sub interval {
+  my $i = shift;
+  '<TABLE>'
+  .join('', map { '<TR><TD>'.($_->description)[$i].'</TR></TD>' }
+      shift->intervals
+  ) . '</TABLE>';
+}
+
+# inefficient but readable
+my $stime_sub = sub { interval(0,shift) };
+my $etime_sub = sub { interval(1,shift) };
+
+my @header     = ( '#',           'Period',       'Start', 'End' );
+my @fields     = ( 'ratetimenum', 'ratetimename', $stime_sub, $etime_sub );
+my @links      = ( ($link) x 2 );
+my @align      = ( 'right', 'left', 'left' );
+
+</%init>
diff --git a/httemplate/edit/process/rate_time.cgi b/httemplate/edit/process/rate_time.cgi
new file mode 100644 (file)
index 0000000..48ed273
--- /dev/null
@@ -0,0 +1,94 @@
+% if ( $error ) {
+%   $cgi->param('error', $error);
+<% $cgi->redirect(popurl(2). "rate_time.cgi?". $cgi->query_string ) %>
+% } else {
+<% $cgi->redirect(popurl(3). "browse/rate_time.html" ) %>
+% }
+%# dumper_html(\%vars, \%old_ints, {$rate_time->intervals}) %>
+<%init>
+my $error = '';
+die "access denied" 
+    unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+my $ratetimenum = $cgi->param('ratetimenum');
+my $ratetimename = $cgi->param('ratetimename');
+my $delete = $cgi->param('delete');
+
+my %vars = $cgi->Vars;
+#warn Dumper(\%vars)."\n";
+
+my $rate_time;
+
+my %old_ints;
+if( $ratetimenum ) {
+  # editing
+  $rate_time = FS::rate_time->by_key($ratetimenum);
+
+  # make a list of existing intervals that will be deleted
+  foreach ($rate_time->intervals) {
+    $old_ints{$_->intervalnum} = $_;
+  }
+
+  if ( $delete ) {
+    $error = $rate_time->delete;
+    # intervals will be deleted later
+  }
+  elsif( $ratetimename ne $rate_time->ratetimename ) {
+    # the only case where the rate_time itself must be replaced
+    $rate_time->ratetimename($ratetimename);
+    $error = $rate_time->replace;
+  }
+}
+else { #!$ratetimenum, adding new
+  $rate_time = FS::rate_time->new({ ratetimename => $ratetimename });
+  $error = $rate_time->insert;
+  $ratetimenum = $rate_time->ratetimenum;
+}
+
+if(!$delete and !$error) {
+  foreach my $i (map { /stime(\d+)/ } keys(%vars)) {
+    my $stime = str2wtime($vars{"stime$i"});
+    my $etime = str2wtime($vars{"etime$i"});
+    next if !defined($stime) or !defined($etime);
+    #warn "$i: $stime-$etime";
+    # try to avoid needlessly wiping and replacing intervals every 
+    # time this is edited.
+    if( %old_ints ) {
+      my $this_int = qsearchs('rate_time_interval', 
+                                    { ratetimenum => $ratetimenum,
+                                      stime       => $stime,
+                                      etime       => $etime, } );
+      if($this_int) { 
+        delete $old_ints{$this_int->intervalnum};
+        #warn "not deleting $stime-$etime\n";
+        next; #$i
+      }
+    }
+    my $new_int = FS::rate_time_interval->new({ ratetimenum => $ratetimenum,
+                                                stime       => $stime,
+                                                etime       => $etime, } );
+    $error = $new_int->insert;
+    #warn "inserting $stime-$etime\n";
+    last if $error;
+  }
+}
+if(!$error) {
+  foreach (values(%old_ints)) {
+    $error = $_->delete;
+    #warn "deleting ".$_->stime.' '.$_->etime."\n";
+    last if $error;
+  }
+}
+
+sub str2wtime {
+  my %days;
+  @days{qw(Sun Mon Tue Wed Thu Fri Sat)} = (0..6);
+  my $str = shift;
+  my ($d, $h, $m, $s, $ampm) = 
+    ($str =~ /^(\w{3}) (\d{2}):(\d{2}):(\d{2}) (\w{2})$/);
+  return () if !$d;
+  $h += 24*$days{$d} + ($ampm eq 'PM' ? 12 : 0);
+  $m += 60*$h;
+  $s += 60*$m;
+  return $s;
+}
+</%init>
index 869ace8..ec04e4c 100644 (file)
@@ -5,6 +5,7 @@
      'labels' => { 'ratedetailnum'       => 'Rate', #should hide...
                    'dest_regionname'     => 'Region',
                    'dest_prefixes_short' => 'Prefix(es)',
+                   'rate_time_name'      => 'Time period',
                    'min_included'        => 'Included minutes/calls',
                    'conn_charge'         => 'Connection charge',
                    'conn_sec'            => 'For',
                    { field=>'ratenum',             type=>'hidden', },
                    { field=>'orig_regionnum',      type=>'hidden', },
                    { field=>'dest_regionnum',      type=>'hidden', },
+                   { field=>'ratetimenum',         type=>'hidden', },
                    { field=>'dest_regionname',     type=>'fixed',  },
                    { field=>'dest_prefixes_short', type=>'fixed',  },
+                   { field=>'rate_time_name',      type=>'fixed',  },
                    { field=>'min_included',        type=>'text',  size=>5 },
                    { field=>'conn_charge',         type=>'money', size=>4 },
                    { field          =>'conn_sec',
                    },
 
                  ],
+     'new_hashref_callback' => sub {
+        { ratenum        => $cgi->param('ratenum'),
+          dest_regionnum => $cgi->param('dest_regionnum'),
+          ratetimenum    => $cgi->param('ratetimenum'),
+        }
+      },
    )
 %>
 <%once>
diff --git a/httemplate/edit/rate_time.cgi b/httemplate/edit/rate_time.cgi
new file mode 100644 (file)
index 0000000..230aef8
--- /dev/null
@@ -0,0 +1,44 @@
+<% include("/elements/header.html","$action Time Period", menubar(
+      'Rate plans' => "${p}browse/rate.cgi",
+    ) )
+%>
+
+<% include('/elements/error.html') %>
+
+<FORM METHOD="POST" ACTION="<% "${p}edit/process/rate_time.cgi" %>">
+<INPUT TYPE="hidden" NAME="ratetimenum" VALUE="<% $ratetimenum %>">
+<% ntable('#cccccc',2) %>
+<TABLE>
+  <TR>
+    <TH ALIGN="right">Period name</TH>
+    <TD><INPUT TYPE="text" NAME="ratetimename" VALUE="<% $rate_time ? $rate_time->ratetimename : '' %>"></TD>
+  </TR>
+</TABLE>
+<% include('/elements/auto-table.html', 
+                      header => [ 'Start', 'End' ],
+                      fields => [ 'stime', 'etime' ],
+                      size   => [ 18, 18 ],
+                      maxl   => [ 15, 15 ],
+                      align  => [ 'right', 'right' ],
+                      data   => \@data,
+   ) %>
+<INPUT TYPE="submit" VALUE="<% $rate_time ? 'Apply changes' : 'Add period'%>">
+</FORM>
+<BR>
+<A HREF="<% "${p}edit/process/rate_time.cgi?ratetimenum=$ratetimenum;delete=1" %>">Delete this period</A>
+<% include('/elements/footer.html') %>
+
+<%init>
+my $ratetimenum = ($cgi->keywords)[0] || '';
+my $action = 'Add';
+my $rate_time;
+my @data = ();
+
+if($ratetimenum) {
+  $action = 'Edit';
+  $rate_time = qsearchs('rate_time', {ratetimenum => $ratetimenum})
+    or die "ratetimenum $ratetimenum not found";
+  @data = $rate_time->description;
+}
+
+</%init>
diff --git a/httemplate/elements/auto-table.html b/httemplate/elements/auto-table.html
new file mode 100644 (file)
index 0000000..89d6eac
--- /dev/null
@@ -0,0 +1,146 @@
+<%doc>
+
+Example:
+<% include('/elements/auto-table.html',
+
+              ###
+              # required
+              ###
+
+              'header'        => [ '#',  'Item', 'Amount' ],
+              'fields'        => [ 'id', 'name', 'amount' ],
+
+              ###
+              # highly recommended
+              ###
+
+              'size'          => [ 4, 12, 8 ],
+              'maxl'          => [ 4, 12, 8 ],
+              'align'         => [ 'right', 'left', 'right' ],
+
+              ###
+              # optional
+              ###
+
+              'data'          => [ [ 1,  'Widget',      25 ], 
+                                   [ 12, 'Super Widget, 7  ] ],
+              #or
+              'records'       => [ qsearch('item', { } ) ],
+              # or any other array of FS::Record objects
+
+              'prefix'        => 'mytable_',
+) %>
+
+Values will be passed through as "mytable_id1", etc.
+</%doc>
+
+<TABLE ID="<% $prefix %>AutoTable" BGCOLOR="#cccccc" BORDER=0 CELLSPACING=0>
+  <TR>
+% foreach (@header) {
+    <TH><% $_ %></TH>
+% }
+  </TR>
+% my $row = 0;
+% for ( $row = 0; $row < scalar @data; $row++ ) {
+  <TR>
+%   my $col = 0;
+%   for ( $col = 0; $col < scalar @fields; $col++ ) {
+%     my $id = $prefix . $fields[$col] . $row;
+    <TD>
+      <INPUT TYPE      = "text"
+             NAME      = "<% $id %>"
+             ID        = "<% $id %>"
+             SIZE      = <% $size[$col] %>
+             MAXLENGTH = <% $maxl[$col] %>
+             STYLE     = "text-align:<% $align[$col] %>"
+             VALUE     = "<% $data[$row][$col] %>"
+             onchange  = "possiblyAddRow();"
+      >
+    </TD>
+%   }
+    <TD>
+      <IMG SRC     = "<% "${p}images/cross.png" %>" 
+           ALT     = "X" 
+           onclick = "deleteThisRow(this);"
+           >
+    </TD>
+  </TR>
+% }
+</TABLE>
+
+<SCRIPT TYPE="text/javascript">
+  var <% $prefix %>rownum = <% $row %>;
+  var <% $prefix %>table = document.getElementById('<% $prefix %>AutoTable');
+
+  function rownum_of(obj) {
+    return (obj.parentNode.parentNode.sectionRowIndex);
+  }
+
+  function possiblyAddRow() {
+    if ( <% $prefix %>rownum == rownum_of(this) ) {
+      <% $prefix %>addRow();
+    }
+  }
+
+  function <% $prefix %>addRow() {
+    var row = <% $prefix %>table.insertRow(<% $prefix %>rownum + 1);
+%   my $col = 0;
+%   for( $col = 0; $col < scalar @fields; $col++ ) {
+%     my $field = $prefix.$fields[$col];
+    var <% $field %>_cell = document.createElement('TD');
+      var <% $field %>_input = document.createElement('INPUT');
+      <% $field %>_input.setAttribute('name', '<% $field %>'+<% $prefix %>rownum);
+      <% $field %>_input.setAttribute('id',   '<% $field %>'+<% $prefix %>rownum);
+      <% $field %>_input.setAttribute('type', 'text');
+      <% $field %>_input.setAttribute('size', <% $size[$col] %>);
+      <% $field %>_input.setAttribute('maxlength', <% $maxl[$col] %>);
+      <% $field %>_input.style.textAlign = '<% $align[$col] %>';
+      <% $field %>_input.onchange = possiblyAddRow;
+      <% $field %>_cell.appendChild(<% $field %>_input);
+    row.appendChild(<% $field %>_cell);
+%   }
+    var delcell = document.createElement('TD');
+      var delinput = document.createElement('IMG');
+      delinput.setAttribute('src', '<% "${p}images/cross.png" %>');
+      delinput.setAttribute('alt', 'X');
+      delinput.setAttribute('onclick', 'deleteThisRow(this);');
+      delcell.appendChild(delinput);
+    row.appendChild(delcell);
+
+    <% $prefix %>rownum++;
+  }
+
+  function deleteThisRow(obj) {
+    if(<% $prefix %>rownum == rownum_of(obj))  {
+      <% $prefix %>addRow();
+    }
+    <% $prefix %>table.deleteRow(rownum_of(obj));
+    <% $prefix %>rownum--;
+    return(false);
+  }
+
+  <% $prefix %>addRow();
+</SCRIPT>
+
+<%init>
+my %opt = @_;
+
+my @header = @{ $opt{'header'} };
+my @fields = @{ $opt{'fields'} };
+my @data = ();
+if($opt{'data'}) {
+  @data = @{ $opt{'data'} };
+}
+elsif($opt{'records'}) {
+  foreach my $rec (@{ $opt{'records'} }) {
+    push @data, [ map { $rec->getfield($_) } @fields ];
+  }
+}
+# else @data = ();
+
+my $prefix = $opt{'prefix'};
+my @size = $opt{'size'} ? @{ $opt{'size'} } : (map {16} @fields);
+my @maxl = $opt{'maxl'} ? @{ $opt{'maxl'} } : @size;
+my @align = $opt{'align'} ? @{ $opt{'align'} } : (map {'right'} @fields);
+
+</%init>
diff --git a/httemplate/misc/delete-rate_detail.html b/httemplate/misc/delete-rate_detail.html
new file mode 100755 (executable)
index 0000000..30856a7
--- /dev/null
@@ -0,0 +1,20 @@
+% if ( $error ) {
+%   errorpage($error);
+% } else {
+<% header('Rate deleted') %>
+    <SCRIPT TYPE="text/javascript">
+      window.top.location.reload();
+    </SCRIPT>
+    </BODY></HTML>
+% }
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my ($query) = $cgi->keywords;
+$query =~ /^(\d+)$/ or die "Illegal ratedetailnum";
+my $rate_detail = FS::rate_detail->by_key($1);
+my $error = $rate_detail->delete;
+
+</%init>