service label localization, internals and UI, #71347
[freeside.git] / FS / FS / part_svc.pm
index aacc3ab..dcc7843 100644 (file)
@@ -1,13 +1,24 @@
 package FS::part_svc;
 package FS::part_svc;
+use base qw(FS::o2m_Common FS::Record);
 
 use strict;
 
 use strict;
-use vars qw( @ISA );
+use vars qw( $DEBUG );
+use Tie::IxHash;
 use FS::Record qw( qsearch qsearchs fields dbh );
 use FS::Record qw( qsearch qsearchs fields dbh );
+use FS::Schema qw( dbdef );
 use FS::part_svc_column;
 use FS::part_export;
 use FS::export_svc;
 use FS::part_svc_column;
 use FS::part_export;
 use FS::export_svc;
+use FS::cust_svc;
+use FS::part_svc_class;
+use FS::part_svc_msgcat;
 
 
-@ISA = qw(FS::Record);
+FS::UID->install_callback(sub {
+    # preload the cache and make sure all modules load
+    my $svc_defs = FS::part_svc->_svc_defs;
+});
+
+$DEBUG = 0;
 
 =head1 NAME
 
 
 =head1 NAME
 
@@ -21,8 +32,12 @@ FS::part_svc - Object methods for part_svc objects
   $record = new FS::part_svc { 'column' => 'value' };
 
   $error = $record->insert;
   $record = new FS::part_svc { 'column' => 'value' };
 
   $error = $record->insert;
+  $error = $record->insert( [ 'pseudofield' ] );
+  $error = $record->insert( [ 'pseudofield' ], \%exportnums );
 
   $error = $new_record->replace($old_record);
 
   $error = $new_record->replace($old_record);
+  $error = $new_record->replace($old_record, '1.3-COMPAT', [ 'pseudofield' ] );
+  $error = $new_record->replace($old_record, '1.3-COMPAT', [ 'pseudofield' ], \%exportnums );
 
   $error = $record->delete;
 
 
   $error = $record->delete;
 
@@ -42,8 +57,23 @@ FS::Record.  The following fields are currently supported:
 =item svcdb - table used for this service.  See L<FS::svc_acct>,
 L<FS::svc_domain>, and L<FS::svc_forward>, among others.
 
 =item svcdb - table used for this service.  See L<FS::svc_acct>,
 L<FS::svc_domain>, and L<FS::svc_forward>, among others.
 
+=item classnum - Optional service class (see L<FS::svc_class>)
+
 =item disabled - Disabled flag, empty or `Y'
 
 =item disabled - Disabled flag, empty or `Y'
 
+=item preserve - Preserve after cancellation, empty or 'Y'
+
+=item selfservice_access - Access allowed to the service via self-service:
+empty for full access, "readonly" for read-only, "hidden" to hide it entirely
+
+=item restrict_edit_password - Require the "Provision customer service" access
+right to change the password field, rather than just "Edit password".  Only
+relevant to svc_acct for now.
+
+=item has_router - Allow the service to have an L<FS::router> connected 
+through it.  Probably only relevant to svc_broadband, svc_acct, and svc_dsl
+for now.
+
 =back
 
 =head1 METHODS
 =back
 
 =head1 METHODS
@@ -59,18 +89,34 @@ database, see L<"insert">.
 
 sub table { 'part_svc'; }
 
 
 sub table { 'part_svc'; }
 
-=item insert EXTRA_FIELDS_ARRAYREF
+=item insert [ EXTRA_FIELDS_ARRAYREF [ , EXPORTNUMS_HASHREF [ , JOB ] ] ] 
 
 Adds this service definition to the database.  If there is an error, returns
 the error, otherwise returns false.
 
 
 Adds this service definition to the database.  If there is an error, returns
 the error, otherwise returns false.
 
-TODOC:
+The following pseudo-fields may be defined, and will be maintained in
+the part_svc_column table appropriately (see L<FS::part_svc_column>).
+
+=over 4
 
 =item I<svcdb>__I<field> - Default or fixed value for I<field> in I<svcdb>.
 
 
 =item I<svcdb>__I<field> - Default or fixed value for I<field> in I<svcdb>.
 
-=item I<svcdb>__I<field>_flag - defines I<svcdb>__I<field> action: null, `D' for default, or `F' for fixed.  For virtual fields, can also be 'X' for excluded.
+=item I<svcdb>__I<field>_label
+
+=item I<svcdb>__I<field>_flag - defines I<svcdb>__I<field> action: null or empty (no default), `D' for default, `F' for fixed (unchangeable), , `S' for selectable choice, `M' for manual selection from inventory, or `A' for automatic selection from inventory.  For virtual fields, can also be 'X' for excluded.
 
 
-TODOC: EXTRA_FIELDS_ARRAYREF
+=item I<svcdb>__I<field>_required - I<field> should always have a true value
+
+=back
+
+If you want to add part_svc_column records for fields that do not exist as
+fields in the I<svcdb> table, make sure to list then in 
+EXTRA_FIELDS_ARRAYREF also.
+
+If EXPORTNUMS_HASHREF is specified (keys are exportnums and values are
+boolean), the appopriate export_svc records will be inserted.
+
+TODOC: JOB
 
 =cut
 
 
 =cut
 
@@ -78,6 +124,9 @@ sub insert {
   my $self = shift;
   my @fields = ();
   @fields = @{shift(@_)} if @_;
   my $self = shift;
   my @fields = ();
   @fields = @{shift(@_)} if @_;
+  my $exportnums = shift || {};
+  my $job = '';
+  $job = shift if @_;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -96,43 +145,77 @@ sub insert {
     return $error;
   }
 
     return $error;
   }
 
+  # add part_svc_column records
+
   my $svcdb = $self->svcdb;
   my $svcdb = $self->svcdb;
-#  my @rows = map { /^${svcdb}__(.*)$/; $1 }
-#    grep ! /_flag$/,
-#      grep /^${svcdb}__/,
-#        fields('part_svc');
-  foreach my $field (
-    grep { $_ ne 'svcnum'
-           && defined( $self->getfield($svcdb.'__'.$_.'_flag') )
-         } (fields($svcdb), @fields)
-  ) {
-    my $part_svc_column = $self->part_svc_column($field);
-    my $previous = qsearchs('part_svc_column', {
-      'svcpart'    => $self->svcpart,
-      'columnname' => $field,
-    } );
+  foreach my $field (fields($svcdb), @fields) {
+    next if $field eq 'svcnum';
+    my $prefix = $svcdb.'__';
+    if ( defined( $self->getfield($prefix.$field.'_flag'))
+      or defined($self->getfield($prefix.$field.'_required'))
+      or length($self->getfield($prefix.$field.'_label'))
+    ) {
+      my $part_svc_column = $self->part_svc_column($field);
+      my $previous = qsearchs('part_svc_column', {
+        'svcpart'    => $self->svcpart,
+        'columnname' => $field,
+      } );
+
+      my $flag  = $self->getfield($prefix.$field.'_flag');
+      my $label = $self->getfield($prefix.$field.'_label');
+      my $required = $self->getfield($prefix.$field.'_required') ? 'Y' : '';
+      if ( uc($flag) =~ /^([A-Z])$/ || $label !~ /^\s*$/ ) {
+
+        if ( uc($flag) =~ /^([A-Z])$/ ) {
+          my $parser = FS::part_svc->svc_table_fields($svcdb)->{$field}->{parse}
+                       || sub { shift };
+          $part_svc_column->setfield('columnflag', $1);
+          $part_svc_column->setfield('columnvalue',
+            &$parser($self->getfield($prefix.$field))
+          );
+        }
+
+        $part_svc_column->setfield('columnlabel', $label)
+          if $label !~ /^\s*$/;
+
+        $part_svc_column->setfield('required', $required);
+
+        if ( $previous ) {
+          $error = $part_svc_column->replace($previous);
+        } else {
+          $error = $part_svc_column->insert;
+        }
 
 
-    my $flag = $self->getfield($svcdb.'__'.$field.'_flag');
-    if ( uc($flag) =~ /^([DFX])$/ ) {
-      $part_svc_column->setfield('columnflag', $1);
-      $part_svc_column->setfield('columnvalue',
-        $self->getfield($svcdb.'__'.$field)
-      );
-      if ( $previous ) {
-        $error = $part_svc_column->replace($previous);
       } else {
       } else {
-        $error = $part_svc_column->insert;
+        $error = $previous ? $previous->delete : '';
       }
       }
-    } else {
-      $error = $previous ? $previous->delete : '';
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+
     }
     }
+  }
+
+  # add export_svc records
+  my @exportnums = grep $exportnums->{$_}, keys %$exportnums;
+  my $slice = 100/scalar(@exportnums) if @exportnums;
+  my $done = 0;
+  foreach my $exportnum ( @exportnums ) {
+    my $export_svc = new FS::export_svc ( {
+      'exportnum' => $exportnum,
+      'svcpart'   => $self->svcpart,
+      'role'      => $exportnums->{$exportnum},
+    } );
+    $error = $export_svc->insert($job, $slice*$done++, $slice);
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return $error;
     }
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return $error;
     }
-
   }
 
   }
 
+  # XXX shouldn't this update fixed values?
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   '';
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   '';
@@ -140,7 +223,7 @@ sub insert {
 
 =item delete
 
 
 =item delete
 
-Currently unimplemented.
+Currently unimplemented.  Set the "disabled" field instead.
 
 =cut
 
 
 =cut
 
@@ -149,19 +232,35 @@ sub delete {
 # check & make sure the svcpart isn't in cust_svc or pkg_svc (in any packages)?
 }
 
 # check & make sure the svcpart isn't in cust_svc or pkg_svc (in any packages)?
 }
 
-=item replace OLD_RECORD [ '1.3-COMPAT' [ , EXTRA_FIELDS_ARRAYREF ] ]
+=item replace OLD_RECORD [ '1.3-COMPAT' [ , EXTRA_FIELDS_ARRAYREF [ , EXPORTNUMS_HASHREF [ , JOB ] ] ] ]
 
 Replaces OLD_RECORD with this one in the database.  If there is an error,
 returns the error, otherwise returns false.
 
 TODOC: 1.3-COMPAT
 
 
 Replaces OLD_RECORD with this one in the database.  If there is an error,
 returns the error, otherwise returns false.
 
 TODOC: 1.3-COMPAT
 
-TODOC: EXTRA_FIELDS_ARRAYREF
+TODOC: EXTRA_FIELDS_ARRAYREF (same as insert method)
+
+TODOC: JOB
 
 =cut
 
 sub replace {
   my ( $new, $old ) = ( shift, shift );
 
 =cut
 
 sub replace {
   my ( $new, $old ) = ( shift, shift );
+  my $compat = '';
+  my @fields = ();
+  my $exportnums;
+  my $job = '';
+  if ( @_ && $_[0] eq '1.3-COMPAT' ) {
+    shift;
+    $compat = '1.3';
+    @fields = @{shift(@_)} if @_;
+    $exportnums = @_ ? shift : '';
+    $job = shift if @_;
+  } else {
+    return 'non-1.3-COMPAT interface not yet written';
+    #not yet implemented
+  }
 
   return "Can't change svcdb for an existing service definition!"
     unless $old->svcdb eq $new->svcdb;
 
   return "Can't change svcdb for an existing service definition!"
     unless $old->svcdb eq $new->svcdb;
@@ -183,42 +282,118 @@ sub replace {
     return $error;
   }
 
     return $error;
   }
 
-  if ( @_ && $_[0] eq '1.3-COMPAT' ) {
-    shift;
-    my @fields = ();
-    @fields = @{shift(@_)} if @_;
+  if ( $compat eq '1.3' ) {
 
 
-    my $svcdb = $new->svcdb;
-    foreach my $field (
-      grep { $_ ne 'svcnum'
-             && defined( $new->getfield($svcdb.'__'.$_.'_flag') )
-           } (fields($svcdb),@fields)
-    ) {
-      my $part_svc_column = $new->part_svc_column($field);
-      my $previous = qsearchs('part_svc_column', {
-        'svcpart'    => $new->svcpart,
-        'columnname' => $field,
-      } );
+   # maintain part_svc_column records
 
 
-      my $flag = $new->getfield($svcdb.'__'.$field.'_flag');
-      if ( uc($flag) =~ /^([DFX])$/ ) {
-        $part_svc_column->setfield('columnflag', $1);
-        $part_svc_column->setfield('columnvalue',
-          $new->getfield($svcdb.'__'.$field)
-        );
-        if ( $previous ) {
-          $error = $part_svc_column->replace($previous);
+    my $svcdb = $new->svcdb;
+    foreach my $field (fields($svcdb),@fields) {
+      next if $field eq 'svcnum';
+      my $prefix = $svcdb.'__';
+      if ( defined( $new->getfield($prefix.$field.'_flag'))
+        or defined($new->getfield($prefix.$field.'_required'))
+        or length($new->getfield($prefix.$field.'_label'))
+      ) {
+        my $part_svc_column = $new->part_svc_column($field);
+        my $previous = qsearchs('part_svc_column', {
+          'svcpart'    => $new->svcpart,
+          'columnname' => $field,
+        } );
+
+        my $flag  = $new->getfield($svcdb.'__'.$field.'_flag');
+        my $label = $new->getfield($svcdb.'__'.$field.'_label');
+        my $required = $new->getfield($svcdb.'__'.$field.'_required') ? 'Y' : '';
+        if ( uc($flag) =~ /^([A-Z])$/ || $label !~ /^\s*$/ ) {
+
+          if ( uc($flag) =~ /^([A-Z])$/ ) {
+            $part_svc_column->setfield('columnflag', $1);
+            my $parser = FS::part_svc->svc_table_fields($svcdb)->{$field}->{parse}
+                       || sub { shift };
+            $part_svc_column->setfield('columnvalue',
+              &$parser($new->getfield($svcdb.'__'.$field))
+            );
+          } else {
+            $part_svc_column->setfield('columnflag',  '');
+            $part_svc_column->setfield('columnvalue', '');
+          }
+
+          $part_svc_column->setfield('columnlabel', $label)
+            if $label !~ /^\s*$/;
+
+          $part_svc_column->setfield('required', $required);
+
+          if ( $previous ) {
+            $error = $part_svc_column->replace($previous);
+          } else {
+            $error = $part_svc_column->insert;
+          }
         } else {
         } else {
-          $error = $part_svc_column->insert;
+          $error = $previous ? $previous->delete : '';
+        }
+        if ( $error ) {
+          $dbh->rollback if $oldAutoCommit;
+          return $error;
         }
         }
-      } else {
-        $error = $previous ? $previous->delete : '';
       }
       }
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return $error;
+    }
+
+    # maintain export_svc records
+
+    if ( $exportnums ) { # hash of exportnum => role
+
+      #false laziness w/ edit/process/agent_type.cgi
+      #and, more importantly, with m2m_Common
+      my @new_export_svc = ();
+      foreach my $part_export ( qsearch('part_export', {}) ) {
+        my $exportnum = $part_export->exportnum;
+        my $hashref = {
+          'exportnum' => $exportnum,
+          'svcpart'   => $new->svcpart,
+        };
+        my $export_svc = qsearchs('export_svc', $hashref);
+
+        if ( $export_svc ) {
+          my $old_role = $export_svc->role || 1; # 1 = null in the db
+          if ( ! $exportnums->{$exportnum}
+               or $old_role ne $exportnums->{$exportnum} ) {
+
+            $error = $export_svc->delete;
+            if ( $error ) {
+              $dbh->rollback if $oldAutoCommit;
+              return $error;
+            }
+            undef $export_svc; # on a role change, force it to be reinserted
+
+          }
+        } # if $export_svc
+        if ( ! $export_svc && $exportnums->{$exportnum} ) {
+          # also applies if it's been undef'd because of role change
+          $hashref->{role} = $exportnums->{$exportnum};
+          push @new_export_svc, new FS::export_svc ( $hashref );
+        }
+
+      }
+
+      my $slice = 100/scalar(@new_export_svc) if @new_export_svc;
+      my $done = 0;
+      foreach my $export_svc (@new_export_svc) {
+        $error = $export_svc->insert($job, $slice*$done++, $slice);
+        if ( $error ) {
+          $dbh->rollback if $oldAutoCommit;
+          return $error;
+        }
+        if ( $job ) {
+          $error = $job->update_statustext( int( $slice * $done ) );
+          if ( $error ) {
+            $dbh->rollback if $oldAutoCommit;
+            return $error;
+          }
+        }
       }
       }
+
     }
     }
+
   } else {
     $dbh->rollback if $oldAutoCommit;
     return 'non-1.3-COMPAT interface not yet written';
   } else {
     $dbh->rollback if $oldAutoCommit;
     return 'non-1.3-COMPAT interface not yet written';
@@ -240,19 +415,24 @@ and replace methods.
 
 sub check {
   my $self = shift;
 
 sub check {
   my $self = shift;
-  my $recref = $self->hashref;
 
   my $error;
   $error=
     $self->ut_numbern('svcpart')
     || $self->ut_text('svc')
     || $self->ut_alpha('svcdb')
 
   my $error;
   $error=
     $self->ut_numbern('svcpart')
     || $self->ut_text('svc')
     || $self->ut_alpha('svcdb')
-    || $self->ut_enum('disabled', [ '', 'Y' ] )
-  ;
+    || $self->ut_flag('disabled')
+    || $self->ut_flag('preserve')
+    || $self->ut_enum('selfservice_access', [ '', 'hidden', 'readonly' ] )
+    || $self->ut_foreign_keyn('classnum', 'part_svc_class', 'classnum' )
+    || $self->ut_flag('restrict_edit_password')
+    || $self->ut_flag('has_router')
+;
   return $error if $error;
 
   return $error if $error;
 
-  my @fields = eval { fields( $recref->{svcdb} ) }; #might die
-  return "Unknown svcdb!" unless @fields;
+  my @fields = eval { fields( $self->svcdb ) }; #might die
+  return "Unknown svcdb: ". $self->svcdb. " (Error: $@)"
+    unless @fields;
 
   $self->SUPER::check;
 }
 
   $self->SUPER::check;
 }
@@ -288,8 +468,8 @@ sub all_part_svc_column {
 
 =item part_export [ EXPORTTYPE ]
 
 
 =item part_export [ EXPORTTYPE ]
 
-Returns all exports (see L<FS::part_export>) for this service, or, if an
-export type is specified, only returns exports of the given type.
+Returns a list of all exports (see L<FS::part_export>) for this service, or,
+if an export type is specified, only returns exports of the given type.
 
 =cut
 
 
 =cut
 
@@ -297,20 +477,555 @@ sub part_export {
   my $self = shift;
   my %search;
   $search{'exporttype'} = shift if @_;
   my $self = shift;
   my %search;
   $search{'exporttype'} = shift if @_;
-  map { qsearchs('part_export', { 'exportnum' => $_->exportnum, %search } ) }
-    qsearch('export_svc', { 'svcpart' => $self->svcpart } );
+  map { $_ } #behavior of sort undefined in scalar context
+    sort { $a->weight <=> $b->weight }
+      map { qsearchs('part_export', { 'exportnum'=>$_->exportnum, %search } ) }
+        qsearch('export_svc', { 'svcpart'=>$self->svcpart } );
+}
+
+=item part_export_usage
+
+Returns a list of any exports (see L<FS::part_export>) for this service that
+are capable of reporting usage information.
+
+=cut
+
+sub part_export_usage {
+  my $self = shift;
+  grep $_->can('usage_sessions'), $self->part_export;
+}
+
+=item part_export_did
+
+Returns a list of any exports (see L<FS::part_export>) for this service that
+are capable of returing available DID (phone number) information.
+
+=cut
+
+sub part_export_did {
+  my $self = shift;
+  grep $_->can_get_dids, $self->part_export;
+}
+
+=item part_export_dsl_pull
+
+Returns a list of any exports (see L<FS::part_export>) for this service that
+are capable of pulling/pushing DSL orders.
+
+=cut
+
+sub part_export_dsl_pull {
+    my $self = shift;
+    grep $_->can('dsl_pull'), $self->part_export;
+}
+
+=item cust_svc [ PKGPART ] 
+
+Returns a list of associated customer services (FS::cust_svc records).
+
+If a PKGPART is specified, returns the customer services which are contained
+within packages of that type (see L<FS::part_pkg>).  If PKGPARTis specified as
+B<0>, returns unlinked customer services.
+
+=cut
+
+sub cust_svc {
+  my $self = shift;
+
+  my $hashref = { 'svcpart' => $self->svcpart };
+
+  my( $addl_from, $extra_sql ) = ( '', '' );
+  if ( @_ ) {
+    my $pkgpart = shift;
+    if ( $pkgpart =~ /^(\d+)$/ ) {
+      $addl_from = 'LEFT JOIN cust_pkg USING ( pkgnum )';
+      $extra_sql = "AND pkgpart = $1";
+    } elsif ( $pkgpart eq '0' ) {
+      $hashref->{'pkgnum'} = '';
+    }
+  }
+
+  qsearch({
+    'table'     => 'cust_svc',
+    'addl_from' => $addl_from,
+    'hashref'   => $hashref,
+    'extra_sql' => $extra_sql,
+  });
+}
+
+=item num_cust_svc [ PKGPART ] 
+
+Returns the number of associated customer services (FS::cust_svc records).
+
+If a PKGPART is specified, returns the number of customer services which are
+contained within packages of that type (see L<FS::part_pkg>).  If PKGPART
+is specified as B<0>, returns the number of unlinked customer services.
+
+=cut
+
+sub num_cust_svc {
+  my $self = shift;
+
+  return $self->{Hash}{num_cust_svc}
+    if !@_ && exists($self->{Hash}{num_cust_svc});
+
+  my @param = ( $self->svcpart );
+
+  my( $join, $and ) = ( '', '' );
+  if ( @_ ) {
+    my $pkgpart = shift;
+    if ( $pkgpart ) {
+      $join = 'LEFT JOIN cust_pkg USING ( pkgnum )';
+      $and = 'AND pkgpart = ?';
+      push @param, $pkgpart;
+    } elsif ( $pkgpart eq '0' ) {
+      $and = 'AND pkgnum IS NULL';
+    }
+  }
+
+  my $sth = dbh->prepare(
+    "SELECT COUNT(*) FROM cust_svc $join WHERE svcpart = ? $and"
+  ) or die dbh->errstr;
+  $sth->execute(@param)
+    or die $sth->errstr;
+  $sth->fetchrow_arrayref->[0];
+}
+
+=item num_cust_svc_cancelled
+
+Returns the number of associated customer services that are
+attached to cancelled packages.
+
+=cut
+
+sub num_cust_svc_cancelled {
+  my $self = shift;
+  my $sth = dbh->prepare(
+    "SELECT COUNT(*) FROM cust_svc
+     LEFT JOIN cust_pkg USING ( pkgnum )
+     WHERE svcpart = ?
+     AND cust_pkg.cancel IS NOT NULL"
+  ) or die dbh->errstr;
+  $sth->execute($self->svcpart)
+    or die $sth->errstr;
+  $sth->fetchrow_arrayref->[0];
+}
+
+=item svc_x
+
+Returns a list of associated FS::svc_* records.
+
+=cut
+
+sub svc_x {
+  my $self = shift;
+  map { $_->svc_x } $self->cust_svc;
+}
+
+=item svc_locale LOCALE
+
+Returns a customer-viewable service definition label in the chosen LOCALE.
+If there is no entry for that locale or if LOCALE is empty, returns
+part_svc.svc.
+
+=cut
+
+sub svc_locale {
+  my( $self, $locale ) = @_;
+  return $self->svc unless $locale;
+  my $part_svc_msgcat = qsearchs('part_svc_msgcat', {
+    svcpart => $self->svcpart,
+    locale  => $locale
+  }) or return $self->svc;
+  $part_svc_msgcat->svc;
+}
+
+=back
+
+=head1 CLASS METHODS
+
+=over 4
+
+=cut
+
+my $svc_defs;
+my $svc_info;
+sub _svc_defs {
+
+  return $svc_defs if $svc_defs; #cache
+
+  my $conf = new FS::Conf;
+
+  #false laziness w/part_pkg.pm::plan_info
+
+  my %info;
+  foreach my $INC ( @INC ) {
+    warn "globbing $INC/FS/svc_*.pm\n" if $DEBUG;
+    foreach my $file ( glob("$INC/FS/svc_*.pm") ) {
+
+      warn "attempting to load service table info from $file\n" if $DEBUG;
+      $file =~ /\/(\w+)\.pm$/ or do {
+        warn "unrecognized file in $INC/FS/: $file\n";
+        next;
+      };
+      my $mod = $1;
+
+      if ( $mod =~ /^svc_[A-Z]/ or $mod =~ /^(svc_acct_pop|svc_export_machine)$/ ) {
+        warn "skipping FS::$mod" if $DEBUG;
+       next;
+      }
+
+      eval "use FS::$mod;";
+      if ( $@ ) {
+        die "error using FS::$mod (skipping): $@\n" if $@;
+        next;
+      }
+      unless ( UNIVERSAL::can("FS::$mod", 'table_info') ) {
+        warn "FS::$mod has no table_info method; skipping";
+       next;
+      }
+
+      my $info = "FS::$mod"->table_info;
+      unless ( keys %$info ) {
+        warn "FS::$mod->table_info doesn't return info, skipping\n";
+        next;
+      }
+      warn "got info from FS::$mod: $info\n" if $DEBUG;
+      if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
+        warn "skipping disabled service FS::$mod" if $DEBUG;
+        next;
+      }
+      $info{$mod} = $info;
+
+      # all svc_* modules are required to have h_svc_* modules for invoice
+      # display. check for them as early as possible.
+      eval "use FS::h_$mod;";
+      if ( $@ ) {
+        die "couldn't load history record module h_$mod: $@\n";
+      }
+    }
+  }
+
+
+  tie my %svc_defs, 'Tie::IxHash', 
+    map  { $_ => $info{$_}->{'fields'} }
+    sort { $info{$a}->{'display_weight'} <=> $info{$b}->{'display_weight'} }
+    keys %info,
+  ;
+
+  tie my %svc_info, 'Tie::IxHash',
+    map  { $_ => $info{$_} }
+    sort { $info{$a}->{'display_weight'} <=> $info{$b}->{'display_weight'} }
+    keys %info,
+  ;
+    
+  $svc_info = \%svc_info; #access via svc_table_info  
+  $svc_defs = \%svc_defs; #cache
+  
+}
+
+=item svc_tables
+
+Returns a list of all svc_ tables.
+
+=cut
+
+sub svc_tables {
+  my $class = shift;
+  my $svc_defs = $class->_svc_defs;
+  grep { defined( dbdef->table($_) ) } keys %$svc_defs;
 }
 
 }
 
+=item svc_table_fields TABLE
+
+Given a table name, returns a hashref of field names.  The field names
+returned are those with additional (service-definition related) information,
+not necessarily all database fields of the table.  Pseudo-fields may also
+be returned (i.e. svc_acct.usergroup).
+
+Each value of the hashref is another hashref, which can have one or more of
+the following keys:
+
+=over 4
+
+=item label - Description of the field
+
+=item def_label - Optional description of the field in the context of service definitions
+
+=item type - Currently "text", "select", "checkbox", "textarea", "disabled", 
+some components specified by "select-.*.html", and a bunch more...
+
+=item disable_default - This field should not allow a default value in service definitions
+
+=item disable_fixed - This field should not allow a fixed value in service definitions
+
+=item disable_inventory - This field should not allow inventory values in service definitions
+
+=item select_list - If type is "text", this can be a listref of possible values.
+
+=item select_table - An alternative to select_list, this defines a database table with the possible choices.
+
+=item select_key - Used with select_table, this is the field name of keys
+
+=item select_label - Used with select_table, this is the field name of labels
+
+=item select_allow_empty - Used with select_table, adds an empty option
+
+=item required - This field should always have a true value (do not use with type checkbox or disabled)
+
 =back
 
 =back
 
+=cut
+
+#maybe this should move and be a class method in svc_Common.pm
+sub svc_table_fields {
+  my($class, $table) = @_;
+  my $svc_defs = $class->_svc_defs;
+  my $def = $svc_defs->{$table};
+
+  foreach ( grep !ref($def->{$_}), keys %$def ) {
+
+    #normalize the shortcut in %info hash
+    $def->{$_} = { 'label' => $def->{$_} };
+
+    $def->{$_}{'type'} ||= 'text';
+
+  }
+
+  $def;
+}
+
+=item svc_table_info TABLE
+
+Returns table_info for TABLE from cache, or empty
+hashref if none is found.
+
+Caution:  caches table_info for ALL services when run;
+access a service's table_info directly unless you know
+you're loading them all.
+
+Caution:  does not standardize fields into hashrefs;
+use L</svc_table_fields> to access fields.
+
+=cut
+
+sub svc_table_info {
+  my $class = shift;
+  my $table = shift;
+  $class->_svc_defs; #creates cache if needed
+  return $svc_info->{$table} || {};
+}
+
+=back
+
+=head1 SUBROUTINES
+
+=over 4
+
+=item process
+
+Job-queue processor for web interface adds/edits
+
+=cut
+
+use Data::Dumper;
+sub process {
+  my $job = shift;
+  my $param = shift;
+  warn Dumper($param) if $DEBUG;
+
+  my $old = qsearchs('part_svc', { 'svcpart' => $param->{'svcpart'} }) 
+    if $param->{'svcpart'};
+
+  #unmunge cgp_accessmodes (falze laziness-ish w/edit/process/svc_acct.cgi)
+  $param->{'svc_acct__cgp_accessmodes'} ||=
+    join(' ', sort
+      grep { $_ !~ /^(flag|label)$/ }
+           map { /^svc_acct__cgp_accessmodes_([\w\/]+)$/ or die "no way"; $1; }
+               grep $param->{$_},
+                    grep /^svc_acct__cgp_accessmodes_([\w\/]+)$/,
+                         keys %$param
+        );
+  
+
+  my $new = new FS::part_svc ( {
+    map {
+      $_ => $param->{$_};
+  #  } qw(svcpart svc svcdb)
+    } ( fields('part_svc'),
+        map { my $svcdb = $_;
+              my @fields = fields($svcdb);
+              push @fields, 'usergroup' if $svcdb eq 'svc_acct'
+                                        or $svcdb eq 'svc_broadband'; #kludge
+
+              map {
+                    my $f = $svcdb.'__'.$_;
+                    my $flag = $param->{ $f.'_flag' } || ''; #silence warnings
+                    if ( $flag =~ /^[MAH]$/ ) {
+                      $param->{ $f } = delete( $param->{ $f.'_classnum' } );
+                    }
+                   if ( ( $flag =~ /^[MAHS]$/ or $_ eq 'usergroup' )
+                         and ref($param->{ $f }) ) {
+                      $param->{ $f } = join(',', @{ $param->{ $f } });
+                   }
+                    ( $f, $f.'_flag', $f.'_label', $f.'_required' );
+                  }
+                  @fields;
+
+            } FS::part_svc->svc_tables()
+      )
+  } );
+  
+  my %exportnums =
+    map { $_->exportnum => ( $param->{'exportnum'.$_->exportnum} || '') }
+        qsearch('part_export', {} );
+  foreach my $exportnum (%exportnums) {
+    my $role = $param->{'exportnum'.$exportnum.'_role'};
+    # role is undef if the export has no role selector
+    if ( $exportnums{$exportnum} && $role ) {
+      $exportnums{$exportnum} = $role;
+    }
+  }
+  my $error;
+  if ( $param->{'svcpart'} ) {
+    $error = $new->replace( $old,
+                            '1.3-COMPAT',    #totally bunk, as jeff noted
+                            [ 'usergroup' ],
+                            \%exportnums,
+                            $job
+                          );
+  } else {
+    $error = $new->insert( [ 'usergroup' ],
+                           \%exportnums,
+                           $job,
+                         );
+    $param->{'svcpart'} = $new->getfield('svcpart');
+  }
+
+  $error ||= $new->process_o2m(
+    'table'   => 'part_svc_msgcat',
+    'params'  => $param,
+    'fields'  => [ 'locale', 'svc' ],
+  );
+
+  die "$error\n" if $error;
+}
+
+=item process_bulk_cust_svc
+
+Job-queue processor for web interface bulk customer service changes
+
+=cut
+
+use Data::Dumper;
+sub process_bulk_cust_svc {
+  my $job = shift;
+  my $param = shift;
+  warn Dumper($param) if $DEBUG;
+
+  local($FS::svc_Common::noexport_hack) = 1
+    if $param->{'noexport'};
+
+  my $old_part_svc =
+    qsearchs('part_svc', { 'svcpart' => $param->{'old_svcpart'} } );
+
+  die "Must select a new service definition\n" unless $param->{'new_svcpart'};
+
+  #the rest should be abstracted out to to its own subroutine?
+
+  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;
+
+  local( $FS::cust_svc::ignore_quantity ) = 1;
+
+  my $total = $old_part_svc->num_cust_svc( $param->{'pkgpart'} );
+
+  my $n = 0;
+  foreach my $old_cust_svc ( $old_part_svc->cust_svc( $param->{'pkgpart'} ) ) {
+
+    my $new_cust_svc = new FS::cust_svc { $old_cust_svc->hash };
+
+    $new_cust_svc->svcpart( $param->{'new_svcpart'} );
+    my $error = $new_cust_svc->replace($old_cust_svc);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      die "$error\n" if $error;
+    }
+
+    $error = $job->update_statustext( int( 100 * ++$n / $total ) );
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      die $error if $error;
+    }
+
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  '';
+
+}
+
+sub _upgrade_data {  #class method
+  my ($class, %opts) = @_;
+
+  my @part_svc_column = qsearch('part_svc_column', { 'columnname' => 'usergroup' });
+  foreach my $col ( @part_svc_column ) {
+    next if $col->columnvalue =~ /^[\d,]+$/ || !$col->columnvalue;
+    my @groupnames = split(',',$col->columnvalue);
+    my @groupnums;
+    my $error = '';
+    foreach my $groupname ( @groupnames ) {
+        my $g = qsearchs('radius_group', { 'groupname' => $groupname } );
+        unless ( $g ) {
+            $g = new FS::radius_group {
+                            'groupname' => $groupname,
+                            'description' => $groupname,
+                            };
+            $error = $g->insert;
+            die "Error inserting new radius_group for service definition group \"$groupname\": $error"
+              if $error;
+        }
+        push @groupnums, $g->groupnum;
+    }
+    $col->columnvalue(join(',',@groupnums));
+    $error = $col->replace;
+    die $error if $error;
+  }
+
+  my @badlabels = qsearch({
+    'table' => 'part_svc_column',
+    'hashref' => {},
+    'extra_sql' => 'WHERE columnlabel IN ('.
+      "'Descriptive label for this particular device.',".
+      "'IP address.  Leave blank for automatic assignment.',".
+      "'Maximum upload speed for this service in Kbps.  0 denotes unlimited.',".
+      "'Maximum download speed for this service in Kbps.  0 denotes unlimited.')"
+  });
+  foreach my $col ( @badlabels ) {
+    $col->columnlabel('');
+    my $error = $col->replace;
+    die $error if $error;
+  }
+
+}
+
 =head1 BUGS
 
 Delete is unimplemented.
 
 =head1 BUGS
 
 Delete is unimplemented.
 
-The list of svc_* tables is hardcoded.  When svc_acct_pop is renamed, this
-should be fixed.
+The list of svc_* tables is no longer hardcoded, but svc_acct_pop is skipped
+as a special case until it is renamed.
 
 
-all_part_svc_column method should be documented
+all_part_svc_column methods should be documented
 
 =head1 SEE ALSO
 
 
 =head1 SEE ALSO