allow exports to add links to customer view (#1407)
[freeside.git] / FS / FS / cust_svc.pm
index d6ed7ee..51fc186 100644 (file)
@@ -1,20 +1,27 @@
 package FS::cust_svc;
 
 use strict;
-use vars qw( @ISA );
-use Carp qw( cluck );
-use FS::Record qw( qsearch qsearchs dbh );
+use vars qw( @ISA $DEBUG $me $ignore_quantity );
+use Carp;
+use FS::Conf;
+use FS::Record qw( qsearch qsearchs dbh str2time_sql );
 use FS::cust_pkg;
 use FS::part_pkg;
 use FS::part_svc;
 use FS::pkg_svc;
-use FS::svc_acct;
-use FS::svc_domain;
-use FS::svc_forward;
 use FS::domain_record;
 use FS::part_export;
+use FS::cdr;
 
-@ISA = qw( FS::Record );
+#most FS::svc_ classes are autoloaded in svc_x emthod
+use FS::svc_acct;  #this one is used in the cache stuff
+
+@ISA = qw( FS::cust_main_Mixin FS::Record );
+
+$DEBUG = 0;
+$me = '[cust_svc]';
+
+$ignore_quantity = 0;
 
 sub _cache {
   my $self = shift;
@@ -61,6 +68,8 @@ The following fields are currently supported:
 
 =item svcpart - Service definition (see L<FS::part_svc>)
 
+=item overlimit - date the service exceeded its usage limit
+
 =back
 
 =head1 METHODS
@@ -123,22 +132,78 @@ sub cancel {
 
   my $svc = $self->svc_x;
   if ($svc) {
+
     my $error = $svc->cancel;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return "Error canceling service: $error";
     }
-    $error = $svc->delete;
+    $error = $svc->delete; #this deletes this cust_svc record as well
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return "Error deleting service: $error";
     }
+
+  } else {
+
+    #huh?
+    warn "WARNING: no svc_ record found for svcnum ". $self->svcnum.
+         "; deleting cust_svc only\n"; 
+
+    my $error = $self->delete;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error deleting cust_svc: $error";
+    }
+
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  ''; #no errors
+
+}
+
+=item overlimit [ ACTION ]
+
+Retrieves or sets the overlimit date.  If ACTION is absent, return
+the present value of overlimit.  If ACTION is present, it can
+have the value 'suspend' or 'unsuspend'.  In the case of 'suspend' overlimit
+is set to the current time if it is not already set.  The 'unsuspend' value
+causes the time to be cleared.  
+
+If there is an error on setting, returns the error, otherwise returns false.
+
+=cut
+
+sub overlimit {
+  my $self = shift;
+  my $action = shift or return $self->getfield('overlimit');
+
+  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;
+
+  if ( $action eq 'suspend' ) {
+    $self->setfield('overlimit', time) unless $self->getfield('overlimit');
+  }elsif ( $action eq 'unsuspend' ) {
+    $self->setfield('overlimit', '');
+  }else{
+    die "unexpected action value: $action";
   }
 
-  my $error = $self->delete;
+  local $ignore_quantity = 1;
+  my $error = $self->replace;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
-    return "Error deleting cust_svc: $error";
+    return "Error setting overlimit: $error";
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
@@ -168,15 +233,12 @@ sub replace {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $error = $new->SUPER::replace($old);
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $error if $error;
-  }
-
+  $old = $new->replace_old unless defined($old);
+  
   if ( $new->svcpart != $old->svcpart ) {
     my $svc_x = $new->svc_x;
-    my $new_svc_x = ref($svc_x)->new({$svc_x->hash});
+    my $new_svc_x = ref($svc_x)->new({$svc_x->hash, svcpart=>$new->svcpart });
+    local($FS::Record::nowarn_identical) = 1;
     my $error = $new_svc_x->replace($svc_x);
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
@@ -184,6 +246,12 @@ sub replace {
     }
   }
 
+  my $error = $new->SUPER::replace($old);
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error if $error;
+  }
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   ''; #no error
 
@@ -192,7 +260,7 @@ sub replace {
 =item check
 
 Checks all fields to make sure this is a valid service.  If there is an error,
-returns the error, otehrwise returns false.  Called by the insert and
+returns the error, otherwise returns false.  Called by the insert and
 replace methods.
 
 =cut
@@ -204,6 +272,7 @@ sub check {
     $self->ut_numbern('svcnum')
     || $self->ut_numbern('pkgnum')
     || $self->ut_number('svcpart')
+    || $self->ut_numbern('overlimit')
   ;
   return $error if $error;
 
@@ -220,6 +289,7 @@ sub check {
     # or new FS::pkg_svc ( { 'pkgpart'  => $cust_pkg->pkgpart,
     #                        'svcpart'  => $self->svcpart,
     #                        'quantity' => 0                   } );
+    my $quantity = $pkg_svc ? $pkg_svc->quantity : 0;
 
     my @cust_svc = qsearch('cust_svc', {
       'pkgnum'  => $self->pkgnum,
@@ -227,10 +297,10 @@ sub check {
     });
     return "Already ". scalar(@cust_svc). " ". $part_svc->svc.
            " services for pkgnum ". $self->pkgnum
-      if scalar(@cust_svc) >= $pkg_svc->quantity;
+      if scalar(@cust_svc) >= $quantity && !$ignore_quantity;
   }
 
-  ''; #no error
+  $self->SUPER::check;
 }
 
 =item part_svc
@@ -249,8 +319,8 @@ sub part_svc {
 
 =item cust_pkg
 
-Returns the definition for this service, as a FS::part_svc object (see
-L<FS::part_svc>).
+Returns the package this service belongs to, as a FS::cust_pkg object (see
+L<FS::cust_pkg>).
 
 =cut
 
@@ -259,44 +329,81 @@ sub cust_pkg {
   qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
 }
 
+=item pkg_svc
+
+Returns the pkg_svc record for for this service, if applicable.
+
+=cut
+
+sub pkg_svc {
+  my $self = shift;
+  my $cust_pkg = $self->cust_pkg;
+  return undef unless $cust_pkg;
+
+  qsearchs( 'pkg_svc', { 'svcpart' => $self->svcpart,
+                         'pkgpart' => $cust_pkg->pkgpart,
+                       }
+          );
+}
+
+=item date_inserted
+
+Returns the date this service was inserted.
+
+=cut
+
+sub date_inserted {
+  my $self = shift;
+  $self->h_date('insert');
+}
+
 =item label
 
 Returns a list consisting of:
 - The name of this service (from part_svc)
 - A meaningful identifier (username, domain, or mail alias)
 - The table name (i.e. svc_domain) for this service
+- svcnum
+
+Usage example:
+
+  my($label, $value, $svcdb) = $cust_svc->label;
 
 =cut
 
 sub label {
   my $self = shift;
-  my $svcdb = $self->part_svc->svcdb;
+  carp "FS::cust_svc::label called on $self" if $DEBUG;
   my $svc_x = $self->svc_x
-    or die "can't find $svcdb.svcnum ". $self->svcnum;
-  my $tag;
-  if ( $svcdb eq 'svc_acct' ) {
-    $tag = $svc_x->email;
-  } elsif ( $svcdb eq 'svc_forward' ) {
-    my $svc_acct = qsearchs( 'svc_acct', { 'svcnum' => $svc_x->srcsvc } );
-    $tag = $svc_acct->email. '->';
-    if ( $svc_x->dstsvc ) {
-      $svc_acct = qsearchs( 'svc_acct', { 'svcnum' => $svc_x->dstsvc } );
-      $tag .= $svc_acct->email;
-    } else {
-      $tag .= $svc_x->dst;
-    }
-  } elsif ( $svcdb eq 'svc_domain' ) {
-    $tag = $svc_x->getfield('domain');
-  } elsif ( $svcdb eq 'svc_www' ) {
-    my $domain = qsearchs( 'domain_record', { 'recnum' => $svc_x->recnum } );
-    $tag = $domain->reczone;
-  } elsif ( $svcdb eq 'svc_broadband' ) {
-    $tag = $svc_x->ip_addr . '/' . $svc_x->ip_netmask;
-  } else {
-    cluck "warning: asked for label of unsupported svcdb; using svcnum";
-    $tag = $svc_x->getfield('svcnum');
-  }
-  $self->part_svc->svc, $tag, $svcdb;
+    or return "can't find ". $self->part_svc->svcdb. '.svcnum '. $self->svcnum;
+
+  $self->_svc_label($svc_x);
+}
+
+sub _svc_label {
+  my( $self, $svc_x ) = ( shift, shift );
+
+  (
+    $self->part_svc->svc,
+    $svc_x->label(@_),
+    $self->part_svc->svcdb,
+    $self->svcnum
+  );
+
+}
+
+=item export_links
+
+Returns a list of html elements associated with this services exports.
+
+=cut
+
+sub export_links {
+  my $self = shift;
+  my $svc_x = $self->svc_x
+    or return "can't find ". $self->part_svc->svcdb. '.svcnum '. $self->svcnum;
+
+  $svc_x->export_links;
 }
 
 =item svc_x
@@ -312,6 +419,10 @@ sub svc_x {
   if ( $svcdb eq 'svc_acct' && $self->{'_svc_acct'} ) {
     $self->{'_svc_acct'};
   } else {
+    require "FS/$svcdb.pm";
+    warn "$me svc_x: part_svc.svcpart ". $self->part_svc->svcpart.
+         ", so searching for $svcdb.svcnum ". $self->svcnum. "\n"
+      if $DEBUG;
     qsearchs( $svcdb, { 'svcnum' => $self->svcnum } );
   }
 }
@@ -337,11 +448,11 @@ sub seconds_since {
   $sth->fetchrow_arrayref->[0];
 }
 
-=item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END 
+=item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
 
 See L<FS::svc_acct/seconds_since_sqlradacct>.  Equivalent to
-$cust_svc->svc_x->seconds_since, but more efficient.  Meaningless for records
-where B<svcdb> is not "svc_acct".
+$cust_svc->svc_x->seconds_since_sqlradacct, but more efficient.  Meaningless
+for records where B<svcdb> is not "svc_acct".
 
 =cut
 
@@ -349,30 +460,27 @@ where B<svcdb> is not "svc_acct".
 sub seconds_since_sqlradacct {
   my($self, $start, $end) = @_;
 
-  my $username = $self->svc_x->username;
+  my $svc_x = $self->svc_x;
 
-  my @part_export = $self->part_svc->part_export('sqlradius')
-    or die "no sqlradius export configured for this service type";
+  my @part_export = $self->part_svc->part_export_usage;
+  die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
+      " service definition"
+    unless @part_export;
     #or return undef;
 
   my $seconds = 0;
   foreach my $part_export ( @part_export ) {
 
+    next if $part_export->option('ignore_accounting');
+
     my $dbh = DBI->connect( map { $part_export->option($_) }
                             qw(datasrc username password)    )
       or die "can't connect to sqlradius database: ". $DBI::errstr;
-  
+
     #select a unix time conversion function based on database type
-    my $str2time;
-    if ( $dbh->{Driver}->{Name} eq 'mysql' ) {
-      $str2time = 'UNIX_TIMESTAMP(';
-    } elsif ( $dbh->{Driver}->{Name} eq 'Pg' ) {
-      $str2time = 'EXTRACT( EPOCH FROM ';
-    } else {
-      warn "warning: unknown database type ". $dbh->{Driver}->{Name}.
-           "; guessing how to convert to UNIX timestamps";
-      $str2time = 'extract(epoch from ';
-    }
+    my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
+    
+    my $username = $part_export->export_username($svc_x);
 
     my $query;
   
@@ -393,11 +501,12 @@ sub seconds_since_sqlradacct {
                 FROM radacct
                 WHERE UserName = ?
                   AND $str2time AcctStartTime ) >= ?
-                  AND ( ? - $str2time AcctStartTime ) < 86400
+                  AND $str2time AcctStartTime ) <  ?
+                  AND ( ? - $str2time AcctStartTime ) ) < 86400
                   AND (    $str2time AcctStopTime ) = 0
                                     OR AcctStopTime IS NULL )";
     $sth = $dbh->prepare($query) or die $dbh->errstr;
-    $sth->execute($end, $username, $start, $end)
+    $sth->execute($end, $username, $start, $end, $end)
       or die $sth->errstr. " executing query $query";
     my $start_during = $sth->fetchrow_arrayref->[0];
   
@@ -416,20 +525,20 @@ sub seconds_since_sqlradacct {
     my $end_during = $sth->fetchrow_arrayref->[0];
   
     #find closed (not anymore - or open) sessions which start before the range
-    # but stop after, or are still open, count range start->range end
+    # but stop after, or are still open, count range start->range end
     # don't count open sessions (probably missing stop record)
     $sth = $dbh->prepare("SELECT COUNT(*)
                             FROM radacct
                             WHERE UserName = ?
                               AND $str2time AcctStartTime ) < ?
                               AND ( $str2time AcctStopTime ) >= ?
-                                                            )"
+                                                                  )"
                               #      OR AcctStopTime =  0
-                              #      OR AcctStopTime IS NULL )"
+                              #      OR AcctStopTime IS NULL       )"
     ) or die $dbh->errstr;
     $sth->execute($username, $start, $end ) or die $sth->errstr;
     my $entire_range = ($end-$start) * $sth->fetchrow_arrayref->[0];
-  
+
     $seconds += $regular + $end_during + $start_during + $entire_range;
 
   }
@@ -438,6 +547,152 @@ sub seconds_since_sqlradacct {
 
 }
 
+=item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
+
+See L<FS::svc_acct/attribute_since_sqlradacct>.  Equivalent to
+$cust_svc->svc_x->attribute_since_sqlradacct, but more efficient.  Meaningless
+for records where B<svcdb> is not "svc_acct".
+
+=cut
+
+#note: implementation here, POD in FS::svc_acct
+#(false laziness w/seconds_since_sqlradacct above)
+sub attribute_since_sqlradacct {
+  my($self, $start, $end, $attrib) = @_;
+
+  my $svc_x = $self->svc_x;
+
+  my @part_export = $self->part_svc->part_export_usage;
+  die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
+      " service definition"
+    unless @part_export;
+    #or return undef;
+
+  my $sum = 0;
+
+  foreach my $part_export ( @part_export ) {
+
+    next if $part_export->option('ignore_accounting');
+
+    my $dbh = DBI->connect( map { $part_export->option($_) }
+                            qw(datasrc username password)    )
+      or die "can't connect to sqlradius database: ". $DBI::errstr;
+
+    #select a unix time conversion function based on database type
+    my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
+
+    my $username = $part_export->export_username($svc_x);
+
+    my $sth = $dbh->prepare("SELECT SUM($attrib)
+                               FROM radacct
+                               WHERE UserName = ?
+                                 AND $str2time AcctStopTime ) >= ?
+                                 AND $str2time AcctStopTime ) <  ?
+                                 AND AcctStopTime IS NOT NULL"
+    ) or die $dbh->errstr;
+    $sth->execute($username, $start, $end) or die $sth->errstr;
+
+    $sum += $sth->fetchrow_arrayref->[0];
+
+  }
+
+  $sum;
+
+}
+
+=item get_session_history TIMESTAMP_START TIMESTAMP_END
+
+See L<FS::svc_acct/get_session_history>.  Equivalent to
+$cust_svc->svc_x->get_session_history, but more efficient.  Meaningless for
+records where B<svcdb> is not "svc_acct".
+
+=cut
+
+sub get_session_history {
+  my($self, $start, $end, $attrib) = @_;
+
+  #$attrib ???
+
+  my @part_export = $self->part_svc->part_export_usage;
+  die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
+      " service definition"
+    unless @part_export;
+    #or return undef;
+                     
+  my @sessions = ();
+
+  foreach my $part_export ( @part_export ) {
+    push @sessions,
+      @{ $part_export->usage_sessions( $start, $end, $self->svc_x ) };
+  }
+
+  @sessions;
+
+}
+
+=item get_cdrs_for_update
+
+Returns (and SELECTs "FOR UPDATE") all unprocessed (freesidestatus NULL) CDR
+objects (see L<FS::cdr>) associated with this service.
+
+CDRs are associated with svc_phone services via svc_phone.phonenum
+
+=cut
+
+sub get_cdrs_for_update {
+  my($self, %options) = @_;
+
+  my $default_prefix = $options{'default_prefix'};
+
+  #CDRs are now associated with svc_phone services via svc_phone.phonenum
+  #return () unless $self->svc_x->isa('FS::svc_phone');
+  return () unless $self->part_svc->svcdb eq 'svc_phone';
+  my $number = $self->svc_x->phonenum;
+
+  my @cdrs = 
+    qsearch( {
+      'table'      => 'cdr',
+      'hashref'    => { 'freesidestatus' => '',
+                        'charged_party'  => $number
+                      },
+      'extra_sql'  => 'FOR UPDATE',
+    } );
+
+  if ( length($default_prefix) ) {
+    push @cdrs,
+      qsearch( {
+        'table'      => 'cdr',
+        'hashref'    => { 'freesidestatus' => '',
+                          'charged_party'  => "$default_prefix$number",
+                        },
+        'extra_sql'  => 'FOR UPDATE',
+      } );
+  }
+
+  #astricon hack?  config option?
+  push @cdrs,
+    qsearch( {
+      'table'        => 'cdr',
+      'hashref'      => { 'freesidestatus' => '',
+                          'src'            => $number,
+                       },
+      'extra_sql'    => 'FOR UPDATE',
+     } );
+
+  if ( length($default_prefix) ) {
+    push @cdrs,
+      qsearch( {
+        'table'        => 'cdr',
+        'hashref'      => { 'freesidestatus' => '',
+                            'src'            => "$default_prefix$number",
+                       },
+        'extra_sql'    => 'FOR UPDATE',
+       } );
+  }
+
+  @cdrs;
+}
+
 =back
 
 =head1 BUGS