RT# 83251 - created script to update service speeds
[freeside.git] / FS / FS / svc_Common.pm
index 71290f4..b67992d 100644 (file)
@@ -1,20 +1,21 @@
 package FS::svc_Common;
+use base qw( FS::cust_main_Mixin FS::Record );
 
 use strict;
-use vars qw( @ISA $noexport_hack $DEBUG $me
+use vars qw( $noexport_hack $DEBUG $me
              $overlimit_missing_cust_svc_nonfatal_kludge );
 use Carp qw( cluck carp croak confess ); #specify cluck have to specify them all
 use Scalar::Util qw( blessed );
+use Lingua::EN::Inflect qw( PL_N );
+use FS::Conf;
 use FS::Record qw( qsearch qsearchs fields dbh );
-use FS::cust_main_Mixin;
 use FS::cust_svc;
 use FS::part_svc;
 use FS::queue;
 use FS::cust_main;
 use FS::inventory_item;
 use FS::inventory_class;
-
-@ISA = qw( FS::cust_main_Mixin FS::Record );
+use FS::NetworkMonitoringSystem;
 
 $me = '[FS::svc_Common]';
 $DEBUG = 0;
@@ -27,9 +28,8 @@ FS::svc_Common - Object method for all svc_ records
 
 =head1 SYNOPSIS
 
-use FS::svc_Common;
-
-@ISA = qw( FS::svc_Common );
+package svc_myservice;
+use base qw( FS::svc_Common );
 
 =head1 DESCRIPTION
 
@@ -40,27 +40,6 @@ inherit from, i.e. FS::svc_acct.  FS::svc_Common inherits from FS::Record.
 
 =over 4
 
-=item search_sql_field FIELD STRING
-
-Class method which returns an SQL fragment to search for STRING in FIELD.
-
-It is now case-insensitive by default.
-
-=cut
-
-sub search_sql_field {
-  my( $class, $field, $string ) = @_;
-  my $table = $class->table;
-  my $q_string = dbh->quote($string);
-  "LOWER($table.$field) = LOWER($q_string)";
-}
-
-#fallback for services that don't provide a search... 
-sub search_sql {
-  #my( $class, $string ) = @_;
-  '1 = 0'; #false
-}
-
 =item new
 
 =cut
@@ -143,6 +122,15 @@ sub virtual_fields {
 
 =item label
 
+Returns a label to identify a record of this service.
+Label may be displayed on freeside screens, and within customer bills.
+
+For example, $obj->label may return:
+
+ - A provisioned phone number for svc_phone
+ - The mailing list name and e-mail address for svc_mailinglist
+ - The address of a rental property svc_realestate
+
 svc_Common provides a fallback label subroutine that just returns the svcnum.
 
 =cut
@@ -159,17 +147,62 @@ sub label_long {
   $self->label(@_);
 }
 
+sub cust_main {
+  my $self = shift;
+  (($self->cust_svc || return)->cust_pkg || return)->cust_main || return
+}
+
+sub cust_linked {
+  my $self = shift;
+  defined($self->cust_main);
+}
+
 =item check
 
 Checks the validity of fields in this record.
 
-At present, this does nothing but call FS::Record::check (which, in turn, 
-does nothing but run virtual field checks).
+Only checks fields marked as required in table_info or 
+part_svc_column definition.  Should be invoked by service-specific
+check using SUPER.  Invokes FS::Record::check using SUPER.
 
 =cut
 
 sub check {
   my $self = shift;
+
+  ## Checking required fields
+
+  # get fields marked as required in table_info
+  my $required = {};
+  my $labels = {};
+  my $tinfo = $self->can('table_info') ? $self->table_info : {};
+  if ($tinfo->{'manual_require'}) {
+    my $fields = $tinfo->{'fields'} || {};
+    foreach my $field (keys %$fields) {
+      if (ref($fields->{$field}) && $fields->{$field}->{'required'}) {
+        $required->{$field} = 1;
+        $labels->{$field} = $fields->{$field}->{'label'};
+      }
+    }
+    # add fields marked as required in database
+    foreach my $column (
+      qsearch('part_svc_column',{
+        'svcpart' => $self->svcpart,
+        'required' => 'Y'
+      })
+    ) {
+      $required->{$column->columnname} = 1;
+      $labels->{$column->columnname} = $column->columnlabel;
+    }
+    # do the actual checking
+    foreach my $field (keys %$required) {
+      unless (length($self->get($field)) > 0) {
+        my $name = $labels->{$field} || $field;
+        return "$name is required\n"
+      }
+    }
+  }
+
   $self->SUPER::check;
 }
 
@@ -187,12 +220,13 @@ I<depend_jobnum>.
 If I<jobnum> is set to an array reference, the jobnums of any export jobs will
 be added to the referenced array.
 
-If I<child_objects> is set to an array reference of FS::tablename objects (for
-example, FS::acct_snarf objects), they will have their svcnum field set and
-will be inserted after this record, but before any exports are run.  Each
-element of the array can also optionally be a two-element array reference
-containing the child object and the name of an alternate field to be filled in
-with the newly-inserted svcnum, for example C<[ $svc_forward, 'srcsvc' ]>
+If I<child_objects> is set to an array reference of FS::tablename objects
+(for example, FS::svc_export_machine or FS::acct_snarf objects), they
+will have their svcnum field set and will be inserted after this record,
+but before any exports are run.  Each element of the array can also
+optionally be a two-element array reference containing the child object
+and the name of an alternate field to be filled in with the newly-inserted
+svcnum, for example C<[ $svc_forward, 'srcsvc' ]>
 
 If I<depend_jobnum> is set (to a scalar jobnum or an array reference of
 jobnums), all provisioning jobs will have a dependancy on the supplied
@@ -231,6 +265,7 @@ sub insert {
 
   my $svcnum = $self->svcnum;
   my $cust_svc = $svcnum ? qsearchs('cust_svc',{'svcnum'=>$self->svcnum}) : '';
+  my $inserted_cust_svc = 0;
   #unless ( $svcnum ) {
   if ( !$svcnum or !$cust_svc ) {
     $cust_svc = new FS::cust_svc ( {
@@ -244,6 +279,7 @@ sub insert {
       $dbh->rollback if $oldAutoCommit;
       return $error;
     }
+    $inserted_cust_svc  = 1;
     $svcnum = $self->svcnum($cust_svc->svcnum);
   } else {
     #$cust_svc = qsearchs('cust_svc',{'svcnum'=>$self->svcnum});
@@ -255,13 +291,17 @@ sub insert {
     $self->svcpart($cust_svc->svcpart);
   }
 
-  my $error =    $self->preinsert_hook_first
+  my $error =    $self->preinsert_hook_first(%options)
               || $self->set_auto_inventory
               || $self->check
               || $self->_check_duplicate
               || $self->preinsert_hook
               || $self->SUPER::insert;
   if ( $error ) {
+    if ( $inserted_cust_svc ) {
+      my $derror = $cust_svc->delete;
+      die $derror if $derror;
+    }
     $dbh->rollback if $oldAutoCommit;
     return $error;
   }
@@ -316,6 +356,12 @@ sub insert {
 
   }
 
+  my $nms_ip_error = $self->nms_ip_insert;
+  if ( $nms_ip_error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return "error queuing IP insert: $nms_ip_error";
+  }
+
   if ( exists $options{'jobnums'} ) {
     push @{ $options{'jobnums'} }, @jobnums;
   }
@@ -330,6 +376,10 @@ sub preinsert_hook_first { ''; }
 sub _check_duplcate { ''; }
 sub preinsert_hook { ''; }
 sub table_dupcheck_fields { (); }
+sub prereplace_hook { ''; }
+sub prereplace_hook_first { ''; }
+sub predelete_hook { ''; }
+sub predelete_hook_first { ''; }
 
 =item delete [ , OPTION => VALUE ... ]
 
@@ -356,9 +406,13 @@ sub delete {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $error =    $self->SUPER::delete
+  my $error =    $self->cust_svc->check_part_svc_link_unprovision
+              || $self->predelete_hook_first 
+             || $self->SUPER::delete
               || $self->export('delete', @$export_args)
              || $self->return_inventory
+              || $self->release_router
+             || $self->predelete_hook
              || $self->cust_svc->delete
   ;
   if ( $error ) {
@@ -371,15 +425,68 @@ sub delete {
   '';
 }
 
+=item expire DATE
+
+Currently this will only run expire exports if any are attached
+
+=cut
+
+sub expire {
+  my($self,$date) = (shift,shift);
+
+  return 'Expire date must be specified' unless $date;
+    
+  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 $export_args = [$date];
+  my $error = $self->export('expire', @$export_args);
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  '';
+}
+
 =item replace [ OLD_RECORD ] [ HASHREF | OPTION => VALUE ]
 
 Replaces OLD_RECORD with this one.  If there is an error, returns the error,
 otherwise returns false.
 
+Currently available options are: I<child_objects>, I<export_args> and
+I<depend_jobnum>.
+
+If I<child_objects> is set to an array reference of FS::tablename objects
+(for example, FS::svc_export_machine or FS::acct_snarf objects), they
+will have their svcnum field set and will be inserted or replaced after
+this record, but before any exports are run.  Each element of the array
+can also optionally be a two-element array reference containing the
+child object and the name of an alternate field to be filled in with
+the newly-inserted svcnum, for example C<[ $svc_forward, 'srcsvc' ]>
+
+If I<depend_jobnum> is set (to a scalar jobnum or an array reference of
+jobnums), all provisioning jobs will have a dependancy on the supplied
+jobnum(s) (they will not run until the specific job(s) complete(s)).
+
+If I<export_args> is set to an array reference, the referenced list will be
+passed to export commands.
+
 =cut
 
 sub replace {
   my $new = shift;
+  $noexport_hack = $new->no_export if $new->no_export;
 
   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
               ? shift
@@ -390,6 +497,15 @@ sub replace {
       ? shift
       : { @_ };
 
+  my $objects = $options->{'child_objects'} || [];
+
+  my @jobnums = ();
+  local $FS::queue::jobnums = \@jobnums;
+  warn "[$me] replace: set \$FS::queue::jobnums to $FS::queue::jobnums\n"
+    if $DEBUG;
+  my $depend_jobnums = $options->{'depend_jobnum'} || [];
+  $depend_jobnums = [ $depend_jobnums ] unless ref($depend_jobnums);
+
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE';
@@ -401,15 +517,10 @@ sub replace {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $error = $new->set_auto_inventory($old);
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $error;
-  }
-
-  #redundant, but so any duplicate fields are maniuplated as appropriate
-  # (svc_phone.phonenum)
-  $error = $new->check;
+  my $error =  $new->prereplace_hook_first($old)
+            || $new->set_auto_inventory($old)
+            || $new->check; #redundant, but so any duplicate fields are
+                            #maniuplated as appropriate (svc_phone.phonenum)
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -432,9 +543,40 @@ sub replace {
     return $error;
   }
 
+  foreach my $object ( @$objects ) {
+    my($field, $obj);
+    if ( ref($object) eq 'ARRAY' ) {
+      ($obj, $field) = @$object;
+    } else {
+      $obj = $object;
+      $field = 'svcnum';
+    }
+    $obj->$field($new->svcnum);
+
+    my $oldobj = qsearchs( $obj->table, {
+                             $field => $new->svcnum,
+                             map { $_ => $obj->$_ } $obj->_svc_child_partfields,
+                         });
+
+    if ( $oldobj ) {
+      my $pkey = $oldobj->primary_key;
+      $obj->$pkey($oldobj->$pkey);
+      $obj->replace($oldobj);
+    } else {
+      $error = $obj->insert;
+    }
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
   #new-style exports!
   unless ( $noexport_hack ) {
 
+    warn "[$me] replace: \$FS::queue::jobnums is $FS::queue::jobnums\n"
+      if $DEBUG;
+
     my $export_args = $options->{'export_args'} || [];
 
     #not quite false laziness, but same pattern as FS::svc_acct::replace and
@@ -483,6 +625,21 @@ sub replace {
       }
     }
 
+    foreach my $depend_jobnum ( @$depend_jobnums ) {
+      warn "[$me] inserting dependancies on supplied job $depend_jobnum\n"
+        if $DEBUG;
+      foreach my $jobnum ( @jobnums ) {
+        my $queue = qsearchs('queue', { 'jobnum' => $jobnum } );
+        warn "[$me] inserting dependancy for job $jobnum on $depend_jobnum\n"
+          if $DEBUG;
+        my $error = $queue->depend_insert($depend_jobnum);
+        if ( $error ) {
+          $dbh->rollback if $oldAutoCommit;
+          return "error queuing job dependancy: $error";
+        }
+      }
+    }
+
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
@@ -572,6 +729,8 @@ sub setx {
 sub part_svc {
   my $self = shift;
 
+  cluck 'svc_X->part_svc called' if $DEBUG;
+
   #get part_svc
   my $svcpart;
   if ( $self->get('svcpart') ) {
@@ -693,6 +852,9 @@ If there is an error, returns the error, otherwise returns false.
 =cut
 
 sub set_auto_inventory {
+  # don't try to do this during an upgrade
+  return '' if $FS::CurrentUser::upgrade_hack;
+
   my $self = shift;
   my $old = @_ ? shift : '';
 
@@ -726,13 +888,20 @@ sub set_auto_inventory {
     next if $columnflag eq 'A' && $self->$field() ne '';
 
     my $classnum = $part_svc_column->columnvalue;
-    my %hash = ( 'classnum' => $classnum );
+    my %hash;
 
     if ( $columnflag eq 'A' && $self->$field() eq '' ) {
       $hash{'svcnum'} = '';
     } elsif ( $columnflag eq 'M' ) {
       return "Select inventory item for $field" unless $self->getfield($field);
       $hash{'item'} = $self->getfield($field);
+      my $chosen_classnum = $self->getfield($field.'_classnum');
+      if ( grep {$_ == $chosen_classnum} split(',', $classnum) ) {
+        $classnum = $chosen_classnum;
+      }
+      # otherwise the chosen classnum is either (all), or somehow not on 
+      # the list, so ignore it and choose the first item that's in any
+      # class on the list
     }
 
     my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql(
@@ -743,19 +912,30 @@ sub set_auto_inventory {
     my $inventory_item = qsearchs({
       'table'     => 'inventory_item',
       'hashref'   => \%hash,
-      'extra_sql' => "AND $agentnums_sql",
+      'extra_sql' => "AND classnum IN ($classnum) AND $agentnums_sql",
       'order_by'  => 'ORDER BY ( agentnum IS NULL ) '. #agent inventory first
                      ' LIMIT 1 FOR UPDATE',
     });
 
     unless ( $inventory_item ) {
+      # should really only be shown if columnflag eq 'A'...
       $dbh->rollback if $oldAutoCommit;
-      my $inventory_class =
-        qsearchs('inventory_class', { 'classnum' => $classnum } );
-      return "Can't find inventory_class.classnum $classnum"
-        unless $inventory_class;
-      return "Out of ". $inventory_class->classname. "s\n"; #Lingua:: BS
-                                                            #for pluralizing
+      my $message = 'Out of ';
+      my @classnums = split(',', $classnum);
+      foreach ( @classnums ) {
+        my $class = FS::inventory_class->by_key($_)
+          or return "Can't find inventory_class.classnum $_";
+        $message .= PL_N($class->classname);
+        if ( scalar(@classnums) > 2 ) { # english is hard
+          if ( $_ != $classnums[-1] ) {
+            $message .= ', ';
+          }
+        }
+        if ( scalar(@classnums) > 1 and $_ == $classnums[-2] ) {
+          $message .= 'and ';
+        }
+      }
+      return $message;
     }
 
     next if $columnflag eq 'M' && $inventory_item->svcnum == $self->svcnum;
@@ -763,31 +943,39 @@ sub set_auto_inventory {
     $self->setfield( $field, $inventory_item->item );
       #if $columnflag eq 'A' && $self->$field() eq '';
 
-    $inventory_item->svcnum( $self->svcnum );
-    my $ierror = $inventory_item->replace();
-    if ( $ierror ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "Error provisioning inventory: $ierror";
-    }
-
+    # release the old inventory item, if there was one
     if ( $old && $old->$field() && $old->$field() ne $self->$field() ) {
       my $old_inv = qsearchs({
-        'table'   => 'inventory_item',
-        'hashref' => { 'classnum' => $classnum,
-                       'svcnum'   => $old->svcnum,
-                       'item'     => $old->$field(),
-                     },
+        'table'     => 'inventory_item',
+        'hashref'   => { 
+                         'svcnum'   => $old->svcnum,
+                       },
+        'extra_sql' => "AND classnum IN ($classnum) AND ".
+          '( ( svc_field IS NOT NULL AND svc_field = '.$dbh->quote($field).' )'.
+          '  OR ( svc_field IS NULL AND item = '. dbh->quote($old->$field).' )'.
+          ')',
       });
       if ( $old_inv ) {
         $old_inv->svcnum('');
+        $old_inv->svc_field('');
         my $oerror = $old_inv->replace;
         if ( $oerror ) {
           $dbh->rollback if $oldAutoCommit;
           return "Error unprovisioning inventory: $oerror";
         }
+      } else {
+        warn "old inventory_item not found for $field ". $self->$field;
       }
     }
 
+    $inventory_item->svcnum( $self->svcnum );
+    $inventory_item->svc_field( $field );
+    my $ierror = $inventory_item->replace();
+    if ( $ierror ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error provisioning inventory: $ierror";
+    }
+
   }
 
  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
@@ -798,6 +986,9 @@ sub set_auto_inventory {
 
 =item return_inventory
 
+Release all inventory items attached to this service's fields.  Call
+when unprovisioning the service.
+
 =cut
 
 sub return_inventory {
@@ -816,6 +1007,7 @@ sub return_inventory {
 
   foreach my $inventory_item ( $self->inventory_item ) {
     $inventory_item->svcnum('');
+    $inventory_item->svc_field('');
     my $error = $inventory_item->replace();
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
@@ -843,18 +1035,29 @@ sub inventory_item {
   });
 }
 
-=item cust_svc
+=item release_router 
 
-Returns the cust_svc record associated with this svc_ record, as a FS::cust_svc
-object (see L<FS::cust_svc>).
+Delete any routers associated with this service.  This will release their
+address blocks, also.
 
 =cut
 
-sub cust_svc {
+sub release_router {
   my $self = shift;
-  qsearchs('cust_svc', { 'svcnum' => $self->svcnum } );
+  my @routers = qsearch('router', { svcnum => $self->svcnum });
+  foreach (@routers) {
+    my $error = $_->delete;
+    return "$error (removing router '".$_->routername."')" if $error;
+  }
+  '';
 }
 
+
+=item cust_svc
+
+Returns the cust_svc record associated with this svc_ record, as a FS::cust_svc
+object (see L<FS::cust_svc>).
+
 =item suspend
 
 Runs export_suspend callbacks.
@@ -906,13 +1109,54 @@ sub export_getsettings {
   my %defaults = ();
   my $error = $self->export('getsettings', \%settings, \%defaults);
   if ( $error ) {
-    #XXX bubble this up better
     warn "error running export_getsetings: $error";
-    return ( {}, {} );
+    return ( { 'error' => $error }, {} );
   }
   ( \%settings, \%defaults );
 }
 
+=item export_getstatus
+
+Runs export_getstatus callbacks and returns a two item list consisting of an
+HTML status and a status hashref.
+
+=cut
+
+sub export_getstatus {
+  my $self = shift;
+  my $html = '';
+  my %hash = ();
+  my $error = $self->export('getstatus', \$html, \%hash);
+  if ( $error ) {
+    warn "error running export_getstatus: $error";
+    return ( '', { 'error' => $error } );
+  }
+  ( $html, \%hash );
+}
+
+=item export_setstatus
+
+Runs export_setstatus callbacks.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+sub export_setstatus { shift->_export_setstatus_X('setstatus', @_) }
+sub export_setstatus_listadd { shift->_export_setstatus_X('setstatus_listadd', @_) }
+sub export_setstatus_listdel { shift->_export_setstatus_X('setstatus_listdel', @_) }
+sub export_setstatus_vacationadd { shift->_export_setstatus_X('setstatus_vacationadd', @_) }
+sub export_setstatus_vacationdel { shift->_export_setstatus_X('setstatus_vacationdel', @_) }
+
+sub _export_setstatus_X {
+  my( $self, $method, @args ) = @_;
+  my $error = $self->export($method, @args);
+  if ( $error ) {
+    warn "error running export_$method: $error";
+    return $error;
+  }
+  '';
+}
+
 =item export HOOK [ EXPORT_ARGS ]
 
 Runs the provided export hook (i.e. "suspend", "unsuspend") for this service.
@@ -922,7 +1166,9 @@ Runs the provided export hook (i.e. "suspend", "unsuspend") for this service.
 sub export {
   my( $self, $method ) = ( shift, shift );
 
+  # $method must start with export_, $action must be the part after that
   $method = "export_$method" unless $method =~ /^export_/;
+  my ($action) = $method =~ /^export_(\w+)/;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -939,6 +1185,7 @@ sub export {
   unless ( $noexport_hack ) {
     foreach my $part_export ( $self->cust_svc->part_svc->part_export ) {
       next unless $part_export->can($method);
+      next if $part_export->get("no_$action"); # currently only 'no_suspend'
       my $error = $part_export->$method($self, @_);
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
@@ -1013,6 +1260,326 @@ sub clone_kludge_unsuspend {
   shift;
 }
 
+=item find_duplicates MODE FIELDS...
+
+Method used by _check_duplicate routines to find services with duplicate 
+values in specified fields.  Set MODE to 'global' to search across all 
+services, or 'export' to limit to those that share one or more exports 
+with this service.  FIELDS is a list of field names; only services 
+matching in all fields will be returned.  Empty fields will be skipped.
+
+=cut
+
+sub find_duplicates {
+  my $self = shift;
+  my $mode = shift;
+  my @fields = @_;
+
+  my %search = map { $_ => $self->getfield($_) } 
+               grep { length($self->getfield($_)) } @fields;
+  return () if !%search;
+  my @dup = grep { ! $self->svcnum or $_->svcnum != $self->svcnum }
+            qsearch( $self->table, \%search );
+  return () if !@dup;
+  return @dup if $mode eq 'global';
+  die "incorrect find_duplicates mode '$mode'" if $mode ne 'export';
+
+  my $exports = FS::part_export::export_info($self->table);
+  my %conflict_svcparts;
+  my $part_svc = $self->part_svc;
+  foreach my $part_export ( $part_svc->part_export ) {
+    %conflict_svcparts = map { $_->svcpart => 1 } $part_export->export_svc;
+  }
+  return grep { $conflict_svcparts{$_->cust_svc->svcpart} } @dup;
+}
+
+=item getstatus_html
+
+=cut
+
+sub getstatus_html {
+  my $self = shift;
+
+  my $part_svc = $self->cust_svc->part_svc;
+
+  my $html = '';
+
+  foreach my $export ( grep $_->can('export_getstatus'), $part_svc->part_export ) {
+    my $export_html = '';
+    my %hash = ();
+    $export->export_getstatus( $self, \$export_html, \%hash );
+    $html .= $export_html;
+  }
+
+  $html;
+
+}
+
+=item nms_ip_insert
+
+=cut
+
+sub nms_ip_insert {
+  my $self = shift;
+  my $conf = new FS::Conf;
+  return '' unless grep { $self->table eq $_ }
+                     $conf->config('nms-auto_add-svc_ips');
+  my $ip_field = $self->table_info->{'ip_field'};
+
+  my $queue = FS::queue->new( {
+                'job'    => 'FS::NetworkMonitoringSystem::queued_add_router',
+                'svcnum' => $self->svcnum,
+  } );
+  $queue->insert( 'FS::NetworkMonitoringSystem',
+                  $self->$ip_field(),
+                  $conf->config('nms-auto_add-community')
+                );
+}
+
+=item nms_delip
+
+=cut
+
+sub nms_ip_delete {
+#XXX not yet implemented
+}
+
+=item search_sql_field FIELD STRING
+
+Class method which returns an SQL fragment to search for STRING in FIELD.
+
+It is now case-insensitive by default.
+
+=cut
+
+sub search_sql_field {
+  my( $class, $field, $string ) = @_;
+  my $table = $class->table;
+  my $q_string = dbh->quote($string);
+  "LOWER($table.$field) = LOWER($q_string)";
+}
+
+#fallback for services that don't provide a search... 
+sub search_sql {
+  #my( $class, $string ) = @_;
+  '1 = 0'; #false
+}
+sub search_sql_addl_from {
+  '';
+}
+
+=item search HASHREF
+
+Class method which returns a qsearch hash expression to search for parameters
+specified in HASHREF.
+
+Parameters:
+
+=over 4
+
+=item unlinked - set to search for all unlinked services.  Overrides all other options.
+
+=item agentnum
+
+=item custnum
+
+=item svcpart
+
+=item ip_addr
+
+=item pkgpart - arrayref
+
+=item routernum - arrayref
+
+=item sectornum - arrayref
+
+=item towernum - arrayref
+
+=item order_by
+
+=item cancelled - if true, only returns svcs attached to cancelled pkgs;
+if defined and false, only returns svcs not attached to cancelled packages
+
+=back
+
+=cut
+
+### Don't call the 'cancelled' option 'Service Status'
+### There is no such thing
+### See cautionary note in httemplate/browse/part_svc.cgi
+
+sub search {
+  my ($class, $params) = @_;
+
+  my @from = (
+    'LEFT JOIN cust_svc  USING ( svcnum  )',
+    'LEFT JOIN part_svc  USING ( svcpart )',
+    'LEFT JOIN cust_pkg  USING ( pkgnum  )',
+    FS::UI::Web::join_cust_main('cust_pkg', 'cust_pkg'),
+  );
+
+  my @where = ();
+
+  $class->_search_svc($params, \@from, \@where) if $class->can('_search_svc');
+
+#  # domain
+#  if ( $params->{'domain'} ) { 
+#    my $svc_domain = qsearchs('svc_domain', { 'domain'=>$params->{'domain'} } );
+#    #preserve previous behavior & bubble up an error if $svc_domain not found?
+#    push @where, 'domsvc = '. $svc_domain->svcnum if $svc_domain;
+#  }
+#
+#  # domsvc
+#  if ( $params->{'domsvc'} =~ /^(\d+)$/ ) { 
+#    push @where, "domsvc = $1";
+#  }
+
+  #unlinked
+  push @where, 'pkgnum IS NULL' if $params->{'unlinked'};
+
+  #agentnum
+  if ( $params->{'agentnum'} =~ /^(\d+)$/ && $1 ) {
+    push @where, "cust_main.agentnum = $1";
+  }
+
+  #custnum
+  if ( $params->{'custnum'} =~ /^(\d+)$/ && $1 ) {
+    push @where, "cust_pkg.custnum = $1";
+  }
+
+  #customer status
+  if ( $params->{'cust_status'} =~ /^([a-z]+)$/ ) {
+    push @where, FS::cust_main->cust_status_sql . " = '$1'";
+  }
+
+  #customer balance
+  if ( $params->{'balance'} =~ /^\s*(\-?\d*(\.\d{1,2})?)\s*$/ && length($1) ) {
+    my $balance = $1;
+
+    my $age = '';
+    if ( $params->{'balance_days'} =~ /^\s*(\d*(\.\d{1,3})?)\s*$/ && length($1) ) {
+      $age = time - 86400 * $1;
+    }
+    push @where, FS::cust_main->balance_date_sql($age) . " > $balance";
+  }
+
+  #payby
+  if ( $params->{'payby'} && scalar(@{ $params->{'payby'} }) ) {
+    my @payby = map "'$_'", grep /^(\w+)$/, @{ $params->{'payby'} };
+    push @where, 'payby IN ('. join(',', @payby ). ')';
+  }
+
+  #pkgpart
+  ##pkgpart, now properly untainted, can be arrayref
+  #for my $pkgpart ( $params->{'pkgpart'} ) {
+  #  if ( ref $pkgpart ) {
+  #    my $where = join(',', map { /^(\d+)$/ ? $1 : () } @$pkgpart );
+  #    push @where, "cust_pkg.pkgpart IN ($where)" if $where;
+  #  }
+  #  elsif ( $pkgpart =~ /^(\d+)$/ ) {
+  #    push @where, "cust_pkg.pkgpart = $1";
+  #  }
+  #}
+  if ( $params->{'pkgpart'} ) {
+    my @pkgpart = ref( $params->{'pkgpart'} )
+                    ? @{ $params->{'pkgpart'} }
+                    : $params->{'pkgpart'}
+                      ? ( $params->{'pkgpart'} )
+                      : ();
+    @pkgpart = grep /^(\d+)$/, @pkgpart;
+    push @where, 'cust_pkg.pkgpart IN ('. join(',', @pkgpart ). ')' if @pkgpart;
+  }
+
+  #svcnum
+  if ( $params->{'svcnum'} ) {
+    my @svcnum = ref( $params->{'svcnum'} )
+                 ? @{ $params->{'svcnum'} }
+                 : $params->{'svcnum'};
+    @svcnum = grep /^\d+$/, @svcnum;
+    push @where, 'svcnum IN ('. join(',', @svcnum) . ')' if @svcnum;
+  }
+
+  # svcpart
+  if ( $params->{'svcpart'} ) {
+    my @svcpart = ref( $params->{'svcpart'} )
+                    ? @{ $params->{'svcpart'} }
+                    : $params->{'svcpart'}
+                      ? ( $params->{'svcpart'} )
+                      : ();
+    @svcpart = grep /^(\d+)$/, @svcpart;
+    push @where, 'svcpart IN ('. join(',', @svcpart ). ')' if @svcpart;
+  }
+
+  if ( $params->{'exportnum'} =~ /^(\d+)$/ ) {
+    push @from, ' LEFT JOIN export_svc USING ( svcpart )';
+    push @where, "exportnum = $1";
+  }
+
+  if ( defined($params->{'cancelled'}) ) {
+    if ($params->{'cancelled'}) {
+      push @where, "cust_pkg.cancel IS NOT NULL";
+    } else {
+      push @where, "cust_pkg.cancel IS NULL";
+    }
+  }
+
+#  # sector and tower
+#  my @where_sector = $class->tower_sector_sql($params);
+#  if ( @where_sector ) {
+#    push @where, @where_sector;
+#    push @from, ' LEFT JOIN tower_sector USING ( sectornum )';
+#  }
+
+  # here is the agent virtualization
+  #if ($params->{CurrentUser}) {
+  #  my $access_user =
+  #    qsearchs('access_user', { username => $params->{CurrentUser} });
+  #
+  #  if ($access_user) {
+  #    push @where, $access_user->agentnums_sql('table'=>'cust_main');
+  #  }else{
+  #    push @where, "1=0";
+  #  }
+  #} else {
+    push @where, $FS::CurrentUser::CurrentUser->agentnums_sql(
+                   'table'      => 'cust_main',
+                   'null_right' => 'View/link unlinked services',
+                 );
+  #}
+
+  push @where, @{ $params->{'where'} } if $params->{'where'};
+
+  my $addl_from = join(' ', @from);
+  my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
+
+  my $table = $class->table;
+
+  my $count_query = "SELECT COUNT(*) FROM $table $addl_from $extra_sql";
+  #if ( keys %svc_X ) {
+  #  $count_query .= ' WHERE '.
+  #                    join(' AND ', map "$_ = ". dbh->quote($svc_X{$_}),
+  #                                      keys %svc_X
+  #                        );
+  #}
+
+  {
+    'table'       => $table,
+    'hashref'     => {},
+    'select'      => join(', ',
+                       "$table.*",
+                       'part_svc.svc',
+                       'cust_main.custnum',
+                       @{ $params->{'addl_select'} || [] },
+                       FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
+                     ),
+    'addl_from'   => $addl_from,
+    'extra_sql'   => $extra_sql,
+    'order_by'    => $params->{'order_by'},
+    'count_query' => $count_query,
+  };
+
+}
+
 =back
 
 =head1 BUGS
@@ -1029,4 +1596,3 @@ from the base documentation.
 =cut
 
 1;
-