missing commit from nms auto-addition fixes, RT#15536
[freeside.git] / FS / FS / svc_Common.pm
index db2fbbe..9137c3f 100644 (file)
@@ -1,16 +1,27 @@
 package FS::svc_Common;
 
 use strict;
-use vars qw( @ISA $noexport_hack $DEBUG );
+use vars qw( @ISA $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 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;
+use FS::NetworkMonitoringSystem;
 
-@ISA = qw( FS::Record );
+@ISA = qw( FS::cust_main_Mixin FS::Record );
 
+$me = '[FS::svc_Common]';
 $DEBUG = 0;
-#$DEBUG = 1;
+
+$overlimit_missing_cust_svc_nonfatal_kludge = 0;
 
 =head1 NAME
 
@@ -31,8 +42,69 @@ 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
 
+sub new {
+  my $proto = shift;
+  my $class = ref($proto) || $proto;
+  my $self = {};
+  bless ($self, $class);
+
+  unless ( defined ( $self->table ) ) {
+    $self->{'Table'} = shift;
+    carp "warning: FS::Record::new called with table name ". $self->{'Table'};
+  }
+  
+  #$self->{'Hash'} = shift;
+  my $newhash = shift;
+  $self->{'Hash'} = { map { $_ => $newhash->{$_} } qw(svcnum svcpart) };
+
+  $self->setdefault( $self->_fieldhandlers )
+    unless $self->svcnum;
+
+  $self->{'Hash'}{$_} = $newhash->{$_}
+    foreach grep { defined($newhash->{$_}) && length($newhash->{$_}) }
+                 keys %$newhash;
+
+  foreach my $field ( grep !defined($self->{'Hash'}{$_}), $self->fields ) { 
+    $self->{'Hash'}{$field}='';
+  }
+
+  $self->_rebless if $self->can('_rebless');
+
+  $self->{'modified'} = 0;
+
+  $self->_cache($self->{'Hash'}, shift) if $self->can('_cache') && @_;
+
+  $self;
+}
+
+#empty default
+sub _fieldhandlers { {}; }
+
 sub virtual_fields {
 
   # This restricts the fields based on part_svc_column and the svcpart of 
@@ -64,13 +136,41 @@ sub virtual_fields {
     my %flags = map { $_->columnname, $_->columnflag } (
         qsearch ('part_svc_column', { svcpart => $svcpart } )
       );
-    return grep { not ($flags{$_} eq 'X') } @vfields;
+    return grep { not ( defined($flags{$_}) && $flags{$_} eq 'X') } @vfields;
   } else { # Case 3
     return @vfields;
   } 
   return ();
 }
 
+=item label
+
+svc_Common provides a fallback label subroutine that just returns the svcnum.
+
+=cut
+
+sub label {
+  my $self = shift;
+  cluck "warning: ". ref($self). " not loaded or missing label method; ".
+        "using svcnum";
+  $self->svcnum;
+}
+
+sub label_long {
+  my $self = shift;
+  $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.
@@ -110,23 +210,25 @@ 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 insert {
   my $self = shift;
   my %options = @_;
-  warn "FS::svc_Common::insert called with options ".
-     join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
-  if $DEBUG;
+  warn "[$me] insert called with options ".
+       join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
+    if $DEBUG;
 
   my @jobnums = ();
   local $FS::queue::jobnums = \@jobnums;
-  warn "FS::svc_Common::insert: set \$FS::queue::jobnums to $FS::queue::jobnums"
+  warn "[$me] insert: set \$FS::queue::jobnums to $FS::queue::jobnums\n"
     if $DEBUG;
   my $objects = $options{'child_objects'} || [];
   my $depend_jobnums = $options{'depend_jobnum'} || [];
   $depend_jobnums = [ $depend_jobnums ] unless ref($depend_jobnums);
-  my $error;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -139,9 +241,6 @@ sub insert {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  $error = $self->check;
-  return $error if $error;
-
   my $svcnum = $self->svcnum;
   my $cust_svc = $svcnum ? qsearchs('cust_svc',{'svcnum'=>$self->svcnum}) : '';
   #unless ( $svcnum ) {
@@ -152,7 +251,7 @@ sub insert {
       'pkgnum'  => $self->pkgnum,
       'svcpart' => $self->svcpart,
     } );
-    $error = $cust_svc->insert;
+    my $error = $cust_svc->insert;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return $error;
@@ -168,7 +267,12 @@ sub insert {
     $self->svcpart($cust_svc->svcpart);
   }
 
-  $error = $self->SUPER::insert;
+  my $error =    $self->preinsert_hook_first
+              || $self->set_auto_inventory
+              || $self->check
+              || $self->_check_duplicate
+              || $self->preinsert_hook
+              || $self->SUPER::insert;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -193,11 +297,13 @@ sub insert {
   #new-style exports!
   unless ( $noexport_hack ) {
 
-    warn "FS::svc_Common::insert: \$FS::queue::jobnums is $FS::queue::jobnums"
+    warn "[$me] insert: \$FS::queue::jobnums is $FS::queue::jobnums\n"
       if $DEBUG;
 
+    my $export_args = $options{'export_args'} || [];
+
     foreach my $part_export ( $self->cust_svc->part_svc->part_export ) {
-      my $error = $part_export->export_insert($self);
+      my $error = $part_export->export_insert($self, @$export_args);
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
         return "exporting to ". $part_export->exporttype.
@@ -206,11 +312,11 @@ sub insert {
     }
 
     foreach my $depend_jobnum ( @$depend_jobnums ) {
-      warn "inserting dependancies on supplied job $depend_jobnum\n"
+      warn "[$me] inserting dependancies on supplied job $depend_jobnum\n"
         if $DEBUG;
       foreach my $jobnum ( @jobnums ) {
         my $queue = qsearchs('queue', { 'jobnum' => $jobnum } );
-        warn "inserting dependancy for job $jobnum on $depend_jobnum\n"
+        warn "[$me] inserting dependancy for job $jobnum on $depend_jobnum\n"
           if $DEBUG;
         my $error = $queue->depend_insert($depend_jobnum);
         if ( $error ) {
@@ -222,6 +328,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;
   }
@@ -231,7 +343,15 @@ sub insert {
   '';
 }
 
-=item delete
+#fallbacks
+sub preinsert_hook_first { ''; }
+sub _check_duplcate { ''; }
+sub preinsert_hook { ''; }
+sub table_dupcheck_fields { (); }
+sub predelete_hook { ''; }
+sub predelete_hook_first { ''; }
+
+=item delete [ , OPTION => VALUE ... ]
 
 Deletes this account from the database.  If there is an error, returns the
 error, otherwise returns false.
@@ -242,7 +362,8 @@ The corresponding FS::cust_svc record will be deleted as well.
 
 sub delete {
   my $self = shift;
-  my $error;
+  my %options = @_;
+  my $export_args = $options{'export_args'} || [];
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -251,47 +372,95 @@ sub delete {
   local $SIG{TSTP} = 'IGNORE';
   local $SIG{PIPE} = 'IGNORE';
 
-  my $svcnum = $self->svcnum;
-
   my $oldAutoCommit = $FS::UID::AutoCommit;
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  $error = $self->SUPER::delete;
-  return $error if $error;
-
-  #new-style exports!
-  unless ( $noexport_hack ) {
-    foreach my $part_export ( $self->cust_svc->part_svc->part_export ) {
-      my $error = $part_export->export_delete($self);
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return "exporting to ". $part_export->exporttype.
-               " (transaction rolled back): $error";
-      }
-    }
+  my $error =  $self->predelete_hook_first 
+             || $self->SUPER::delete
+              || $self->export('delete', @$export_args)
+             || $self->return_inventory
+             || $self->predelete_hook
+             || $self->cust_svc->delete
+  ;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
   }
 
-  return $error if $error;
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
-  my $cust_svc = $self->cust_svc;
-  $error = $cust_svc->delete;
-  return $error if $error;
+  '';
+}
+
+=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
+=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<export_args> and I<depend_jobnum>.
+
+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, $old) = (shift, shift);
+  my $new = shift;
+
+  my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
+              ? shift
+              : $new->replace_old;
+
+  my $options = 
+    ( ref($_[0]) eq 'HASH' )
+      ? shift
+      : { @_ };
+
+  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';
@@ -304,7 +473,32 @@ sub replace {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $error = $new->SUPER::replace($old);
+  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;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  #if ( $old->username ne $new->username || $old->domsvc != $new->domsvc ) {
+  if ( grep { $old->$_ ne $new->$_ } $new->table_dupcheck_fields ) {
+
+    $new->svcpart( $new->cust_svc->svcpart ) unless $new->svcpart;
+    $error = $new->_check_duplicate;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  $error = $new->SUPER::replace($old);
   if ($error) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -313,6 +507,11 @@ sub replace {
   #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
     #FS::part_export::sqlradius::_export_replace.  List::Compare or something
     #would be useful but too much of a pain in the ass to deploy
@@ -328,7 +527,7 @@ sub replace {
     foreach my $delete_part_export (
       grep { ! $new_exportnum{$_->exportnum} } @old_part_export
     ) {
-      my $error = $delete_part_export->export_delete($old);
+      my $error = $delete_part_export->export_delete($old, @$export_args);
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
         return "error deleting, export to ". $delete_part_export->exporttype.
@@ -339,7 +538,8 @@ sub replace {
     foreach my $replace_part_export (
       grep { $old_exportnum{$_->exportnum} } @new_part_export
     ) {
-      my $error = $replace_part_export->export_replace($new,$old);
+      my $error =
+        $replace_part_export->export_replace( $new, $old, @$export_args);
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
         return "error exporting to ". $replace_part_export->exporttype.
@@ -350,7 +550,7 @@ sub replace {
     foreach my $insert_part_export (
       grep { ! $old_exportnum{$_->exportnum} } @new_part_export
     ) {
-      my $error = $insert_part_export->export_insert($new);
+      my $error = $insert_part_export->export_insert($new, @$export_args );
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
         return "error inserting export to ". $insert_part_export->exporttype.
@@ -358,13 +558,27 @@ 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;
   '';
 }
 
-
 =item setfixed
 
 Sets any fixed fields for this service (see L<FS::part_svc>).  If there is an
@@ -375,7 +589,7 @@ to test the return).  Usually called by the check method.
 
 sub setfixed {
   my $self = shift;
-  $self->setx('F');
+  $self->setx('F', @_);
 }
 
 =item setdefault
@@ -388,20 +602,66 @@ the FS::part_svc object (use ref() to test the return).
 
 sub setdefault {
   my $self = shift;
-  $self->setx('D');
+  $self->setx('D', @_ );
+}
+
+=item set_default_and_fixed
+
+=cut
+
+sub set_default_and_fixed {
+  my $self = shift;
+  $self->setx( [ 'D', 'F' ], @_ );
 }
 
+=item setx FLAG | FLAG_ARRAYREF , [ CALLBACK_HASHREF ]
+
+Sets fields according to the passed in flag or arrayref of flags.
+
+Optionally, a hashref of field names and callback coderefs can be passed.
+If a coderef exists for a given field name, instead of setting the field,
+the coderef is called with the column value (part_svc_column.columnvalue)
+as the single parameter.
+
+=cut
+
 sub setx {
   my $self = shift;
   my $x = shift;
+  my @x = ref($x) ? @$x : ($x);
+  my $coderef = scalar(@_) ? shift : $self->_fieldhandlers;
 
-  my $error;
-
-  $error =
+  my $error =
     $self->ut_numbern('svcnum')
   ;
   return $error if $error;
 
+  my $part_svc = $self->part_svc;
+  return "Unknown svcpart" unless $part_svc;
+
+  #set default/fixed/whatever fields from part_svc
+
+  foreach my $part_svc_column (
+    grep { my $f = $_->columnflag; grep { $f eq $_ } @x } #columnflag in @x
+    $part_svc->all_part_svc_column
+  ) {
+
+    my $columnname  = $part_svc_column->columnname;
+    my $columnvalue = $part_svc_column->columnvalue;
+
+    $columnvalue = &{ $coderef->{$columnname} }( $self, $columnvalue )
+      if exists( $coderef->{$columnname} );
+    $self->setfield( $columnname, $columnvalue );
+
+  }
+
+ $part_svc;
+
+}
+
+sub part_svc {
+  my $self = shift;
+
   #get part_svc
   my $svcpart;
   if ( $self->get('svcpart') ) {
@@ -411,41 +671,226 @@ sub setx {
     return "Unknown svcnum" unless $cust_svc; 
     $svcpart = $cust_svc->svcpart;
   }
-  my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $svcpart } );
+
+  qsearchs( 'part_svc', { 'svcpart' => $svcpart } );
+
+}
+
+=item svc_pbx
+
+Returns the FS::svc_pbx record for this service, if any (see L<FS::svc_pbx>).
+
+Only makes sense if the service has a pbxsvc field (currently, svc_phone and
+svc_acct).
+
+=cut
+
+# XXX FS::h_svc_{acct,phone} could have a history-aware svc_pbx override
+
+sub svc_pbx {
+  my $self = shift;
+  return '' unless $self->pbxsvc;
+  qsearchs( 'svc_pbx', { 'svcnum' => $self->pbxsvc } );
+}
+
+=item pbx_title
+
+Returns the title of the FS::svc_pbx record associated with this service, if
+any.
+
+Only makes sense if the service has a pbxsvc field (currently, svc_phone and
+svc_acct).
+
+=cut
+
+sub pbx_title {
+  my $self = shift;
+  my $svc_pbx = $self->svc_pbx or return '';
+  $svc_pbx->title;
+}
+
+=item pbx_select_hash %OPTIONS
+
+Can be called as an object method or a class method.
+
+Returns a hash SVCNUM => TITLE ...  representing the PBXes this customer
+that may be associated with this service.
+
+Currently available options are: I<pkgnum> I<svcpart>
+
+Only makes sense if the service has a pbxsvc field (currently, svc_phone and
+svc_acct).
+
+=cut
+
+#false laziness w/svc_acct::domain_select_hash
+sub pbx_select_hash {
+  my ($self, %options) = @_;
+  my %pbxes = ();
+  my $part_svc;
+  my $cust_pkg;
+
+  if (ref($self)) {
+    $part_svc = $self->part_svc;
+    $cust_pkg = $self->cust_svc->cust_pkg
+      if $self->cust_svc;
+  }
+
+  $part_svc = qsearchs('part_svc', { 'svcpart' => $options{svcpart} })
+    if $options{'svcpart'};
+
+  $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $options{pkgnum} })
+    if $options{'pkgnum'};
+
+  if ($part_svc && ( $part_svc->part_svc_column('pbxsvc')->columnflag eq 'S'
+                  || $part_svc->part_svc_column('pbxsvc')->columnflag eq 'F')) {
+    %pbxes = map { $_->svcnum => $_->title }
+             map { qsearchs('svc_pbx', { 'svcnum' => $_ }) }
+             split(',', $part_svc->part_svc_column('pbxsvc')->columnvalue);
+  } elsif ($cust_pkg) { # && !$conf->exists('svc_acct-alldomains') ) {
+    %pbxes = map { $_->svcnum => $_->title }
+             map { qsearchs('svc_pbx', { 'svcnum' => $_->svcnum }) }
+             map { qsearch('cust_svc', { 'pkgnum' => $_->pkgnum } ) }
+             qsearch('cust_pkg', { 'custnum' => $cust_pkg->custnum });
+  } else {
+    #XXX agent-virt
+    %pbxes = map { $_->svcnum => $_->title } qsearch('svc_pbx', {} );
+  }
+
+  if ($part_svc && $part_svc->part_svc_column('pbxsvc')->columnflag eq 'D') {
+    my $svc_pbx = qsearchs('svc_pbx',
+      { 'svcnum' => $part_svc->part_svc_column('pbxsvc')->columnvalue } );
+    if ( $svc_pbx ) {
+      $pbxes{$svc_pbx->svcnum}  = $svc_pbx->title;
+    } else {
+      warn "unknown svc_pbx.svcnum for part_svc_column pbxsvc: ".
+           $part_svc->part_svc_column('pbxsvc')->columnvalue;
+
+    }
+  }
+
+  (%pbxes);
+
+}
+
+=item set_auto_inventory
+
+Sets any fields which auto-populate from inventory (see L<FS::part_svc>), and
+also check any manually populated inventory fields.
+
+If there is an error, returns the error, otherwise returns false.
+
+=cut
+
+sub set_auto_inventory {
+  my $self = shift;
+  my $old = @_ ? shift : '';
+
+  my $error =
+    $self->ut_numbern('svcnum')
+  ;
+  return $error if $error;
+
+  my $part_svc = $self->part_svc;
   return "Unkonwn svcpart" unless $part_svc;
 
+  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;
+
   #set default/fixed/whatever fields from part_svc
   my $table = $self->table;
   foreach my $field ( grep { $_ ne 'svcnum' } $self->fields ) {
+
     my $part_svc_column = $part_svc->part_svc_column($field);
-    if ( $part_svc_column->columnflag eq $x ) {
-      $self->setfield( $field, $part_svc_column->columnvalue );
+    my $columnflag = $part_svc_column->columnflag;
+    next unless $columnflag =~ /^[AM]$/;
+
+    next if $columnflag eq 'A' && $self->$field() ne '';
+
+    my $classnum = $part_svc_column->columnvalue;
+    my %hash = ( 'classnum' => $classnum );
+
+    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);
     }
-  }
 
- $part_svc;
+    my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql(
+      'null'  => 1,
+      'table' => 'inventory_item',
+    );
 
-}
+    my $inventory_item = qsearchs({
+      'table'     => 'inventory_item',
+      'hashref'   => \%hash,
+      'extra_sql' => "AND $agentnums_sql",
+      'order_by'  => 'ORDER BY ( agentnum IS NULL ) '. #agent inventory first
+                     ' LIMIT 1 FOR UPDATE',
+    });
 
-=item cust_svc
+    unless ( $inventory_item ) {
+      $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
+    }
 
-Returns the cust_svc record associated with this svc_ record, as a FS::cust_svc
-object (see L<FS::cust_svc>).
+    next if $columnflag eq 'M' && $inventory_item->svcnum == $self->svcnum;
 
-=cut
+    $self->setfield( $field, $inventory_item->item );
+      #if $columnflag eq 'A' && $self->$field() eq '';
 
-sub cust_svc {
-  my $self = shift;
-  qsearchs('cust_svc', { 'svcnum' => $self->svcnum } );
-}
+    $inventory_item->svcnum( $self->svcnum );
+    my $ierror = $inventory_item->replace();
+    if ( $ierror ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error provisioning inventory: $ierror";
+    }
 
-=item suspend
+    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(),
+                     },
+      });
+      if ( $old_inv ) {
+        $old_inv->svcnum('');
+        my $oerror = $old_inv->replace;
+        if ( $oerror ) {
+          $dbh->rollback if $oldAutoCommit;
+          return "Error unprovisioning inventory: $oerror";
+        }
+      }
+    }
 
-Runs export_suspend callbacks.
+  }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+ '';
+
+}
+
+=item return_inventory
 
 =cut
 
-sub suspend {
+sub return_inventory {
   my $self = shift;
 
   local $SIG{HUP} = 'IGNORE';
@@ -459,21 +904,58 @@ sub suspend {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  #new-style exports!
-  unless ( $noexport_hack ) {
-    foreach my $part_export ( $self->cust_svc->part_svc->part_export ) {
-      my $error = $part_export->export_suspend($self);
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return "error exporting to ". $part_export->exporttype.
-               " (transaction rolled back): $error";
-      }
+  foreach my $inventory_item ( $self->inventory_item ) {
+    $inventory_item->svcnum('');
+    my $error = $inventory_item->replace();
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error returning inventory: $error";
     }
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
   '';
+}
 
+=item inventory_item
+
+Returns the inventory items associated with this svc_ record, as
+FS::inventory_item objects (see L<FS::inventory_item>.
+
+=cut
+
+sub inventory_item {
+  my $self = shift;
+  qsearch({
+    'table'     => 'inventory_item',
+    'hashref'   => { 'svcnum' => $self->svcnum, },
+  });
+}
+
+=item cust_svc
+
+Returns the cust_svc record associated with this svc_ record, as a FS::cust_svc
+object (see L<FS::cust_svc>).
+
+=cut
+
+sub cust_svc {
+  my $self = shift;
+  qsearchs('cust_svc', { 'svcnum' => $self->svcnum } );
+}
+
+=item suspend
+
+Runs export_suspend callbacks.
+
+=cut
+
+sub suspend {
+  my $self = shift;
+  my %options = @_;
+  my $export_args = $options{'export_args'} || [];
+  $self->export('suspend', @$export_args);
 }
 
 =item unsuspend
@@ -484,6 +966,73 @@ Runs export_unsuspend callbacks.
 
 sub unsuspend {
   my $self = shift;
+  my %options = @_;
+  my $export_args = $options{'export_args'} || [];
+  $self->export('unsuspend', @$export_args);
+}
+
+=item export_links
+
+Runs export_links callbacks and returns the links.
+
+=cut
+
+sub export_links {
+  my $self = shift;
+  my $return = [];
+  $self->export('links', $return);
+  $return;
+}
+
+=item export_getsettings
+
+Runs export_getsettings callbacks and returns the two hashrefs.
+
+=cut
+
+sub export_getsettings {
+  my $self = shift;
+  my %settings = ();
+  my %defaults = ();
+  my $error = $self->export('getsettings', \%settings, \%defaults);
+  if ( $error ) {
+    #XXX bubble this up better
+    warn "error running export_getsetings: $error";
+    return ( {}, {} );
+  }
+  ( \%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 ) {
+    #XXX bubble this up better
+    warn "error running export_getstatus: $error";
+    return ( '', {} );
+  }
+  ( $html, \%hash );
+}
+
+=item export HOOK [ EXPORT_ARGS ]
+
+Runs the provided export hook (i.e. "suspend", "unsuspend") for this service.
+
+=cut
+
+sub export {
+  my( $self, $method ) = ( shift, shift );
+
+  $method = "export_$method" unless $method =~ /^export_/;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -499,10 +1048,11 @@ sub unsuspend {
   #new-style exports!
   unless ( $noexport_hack ) {
     foreach my $part_export ( $self->cust_svc->part_svc->part_export ) {
-      my $error = $part_export->export_unsuspend($self);
+      next unless $part_export->can($method);
+      my $error = $part_export->$method($self, @_);
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
-        return "error exporting to ". $part_export->exporttype.
+        return "error exporting $method event to ". $part_export->exporttype.
                " (transaction rolled back): $error";
       }
     }
@@ -513,11 +1063,38 @@ sub unsuspend {
 
 }
 
+=item overlimit
+
+Sets or retrieves overlimit date.
+
+=cut
+
+sub overlimit {
+  my $self = shift;
+  #$self->cust_svc->overlimit(@_);
+  my $cust_svc = $self->cust_svc;
+  unless ( $cust_svc ) { #wtf?
+    my $error = "$me overlimit: missing cust_svc record for svc_acct svcnum ".
+                $self->svcnum;
+    if ( $overlimit_missing_cust_svc_nonfatal_kludge ) {
+      cluck "$error; continuing anyway as requested";
+      return '';
+    } else {
+      confess $error;
+    }
+  }
+  $cust_svc->overlimit(@_);
+}
+
 =item cancel
 
-Stub - returns false (no error) so derived classes don't need to define these
+Stub - returns false (no error) so derived classes don't need to define this
 methods.  Called by the cancel method of FS::cust_pkg (see L<FS::cust_pkg>).
 
+This method is called *before* the deletion step which actually deletes the
+services.  This method should therefore only be used for "pre-deletion"
+cancellation steps, if necessary.
+
 =cut
 
 sub cancel { ''; }
@@ -546,12 +1123,98 @@ 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
+}
+
 =back
 
 =head1 BUGS
 
 The setfixed method return value.
 
+B<export> method isn't used by insert and replace methods yet.
+
 =head1 SEE ALSO
 
 L<FS::Record>, L<FS::cust_svc>, L<FS::part_svc>, L<FS::cust_pkg>, schema.html