service refactor!
[freeside.git] / FS / FS / part_svc.pm
1 package FS::part_svc;
2
3 use strict;
4 use vars qw( @ISA $DEBUG );
5 use Tie::IxHash;
6 use FS::Record qw( qsearch qsearchs fields dbh );
7 use FS::Schema qw( dbdef );
8 use FS::part_svc_column;
9 use FS::part_export;
10 use FS::export_svc;
11 use FS::cust_svc;
12
13 @ISA = qw(FS::Record);
14
15 $DEBUG = 0;
16
17 =head1 NAME
18
19 FS::part_svc - Object methods for part_svc objects
20
21 =head1 SYNOPSIS
22
23   use FS::part_svc;
24
25   $record = new FS::part_svc \%hash
26   $record = new FS::part_svc { 'column' => 'value' };
27
28   $error = $record->insert;
29   $error = $record->insert( [ 'pseudofield' ] );
30   $error = $record->insert( [ 'pseudofield' ], \%exportnums );
31
32   $error = $new_record->replace($old_record);
33   $error = $new_record->replace($old_record, '1.3-COMPAT', [ 'pseudofield' ] );
34   $error = $new_record->replace($old_record, '1.3-COMPAT', [ 'pseudofield' ], \%exportnums );
35
36   $error = $record->delete;
37
38   $error = $record->check;
39
40 =head1 DESCRIPTION
41
42 An FS::part_svc represents a service definition.  FS::part_svc inherits from
43 FS::Record.  The following fields are currently supported:
44
45 =over 4
46
47 =item svcpart - primary key (assigned automatically for new service definitions)
48
49 =item svc - text name of this service definition
50
51 =item svcdb - table used for this service.  See L<FS::svc_acct>,
52 L<FS::svc_domain>, and L<FS::svc_forward>, among others.
53
54 =item disabled - Disabled flag, empty or `Y'
55
56 =back
57
58 =head1 METHODS
59
60 =over 4
61
62 =item new HASHREF
63
64 Creates a new service definition.  To add the service definition to the
65 database, see L<"insert">.
66
67 =cut
68
69 sub table { 'part_svc'; }
70
71 =item insert [ EXTRA_FIELDS_ARRAYREF [ , EXPORTNUMS_HASHREF [ , JOB ] ] ] 
72
73 Adds this service definition to the database.  If there is an error, returns
74 the error, otherwise returns false.
75
76 The following pseudo-fields may be defined, and will be maintained in
77 the part_svc_column table appropriately (see L<FS::part_svc_column>).
78
79 =over 4
80
81 =item I<svcdb>__I<field> - Default or fixed value for I<field> in I<svcdb>.
82
83 =item I<svcdb>__I<field>_flag - defines I<svcdb>__I<field> action: null or empty (no default), `D' for default, `F' for fixed (unchangeable), `M' for manual selection from inventory, or `A' for automatic selection from inventory.  For virtual fields, can also be 'X' for excluded.
84
85 =back
86
87 If you want to add part_svc_column records for fields that do not exist as
88 (real or virtual) fields in the I<svcdb> table, make sure to list then in 
89 EXTRA_FIELDS_ARRAYREF also.
90
91 If EXPORTNUMS_HASHREF is specified (keys are exportnums and values are
92 boolean), the appopriate export_svc records will be inserted.
93
94 TODOC: JOB
95
96 =cut
97
98 sub insert {
99   my $self = shift;
100   my @fields = ();
101   my @exportnums = ();
102   @fields = @{shift(@_)} if @_;
103   if ( @_ ) {
104     my $exportnums = shift;
105     @exportnums = grep $exportnums->{$_}, keys %$exportnums;
106   }
107   my $job = '';
108   $job = shift if @_;
109
110   local $SIG{HUP} = 'IGNORE';
111   local $SIG{INT} = 'IGNORE';
112   local $SIG{QUIT} = 'IGNORE';
113   local $SIG{TERM} = 'IGNORE';
114   local $SIG{TSTP} = 'IGNORE';
115   local $SIG{PIPE} = 'IGNORE';
116
117   my $oldAutoCommit = $FS::UID::AutoCommit;
118   local $FS::UID::AutoCommit = 0;
119   my $dbh = dbh;
120
121   my $error = $self->SUPER::insert;
122   if ( $error ) {
123     $dbh->rollback if $oldAutoCommit;
124     return $error;
125   }
126
127   # add part_svc_column records
128
129   my $svcdb = $self->svcdb;
130 #  my @rows = map { /^${svcdb}__(.*)$/; $1 }
131 #    grep ! /_flag$/,
132 #      grep /^${svcdb}__/,
133 #        fields('part_svc');
134   foreach my $field (
135     grep { $_ ne 'svcnum'
136            && defined( $self->getfield($svcdb.'__'.$_.'_flag') )
137          } (fields($svcdb), @fields)
138   ) {
139     my $part_svc_column = $self->part_svc_column($field);
140     my $previous = qsearchs('part_svc_column', {
141       'svcpart'    => $self->svcpart,
142       'columnname' => $field,
143     } );
144
145     my $flag = $self->getfield($svcdb.'__'.$field.'_flag');
146     #if ( uc($flag) =~ /^([DFMAX])$/ ) {
147     if ( uc($flag) =~ /^([A-Z])$/ ) { #part_svc_column will test it
148       $part_svc_column->setfield('columnflag', $1);
149       $part_svc_column->setfield('columnvalue',
150         $self->getfield($svcdb.'__'.$field)
151       );
152       if ( $previous ) {
153         $error = $part_svc_column->replace($previous);
154       } else {
155         $error = $part_svc_column->insert;
156       }
157     } else {
158       $error = $previous ? $previous->delete : '';
159     }
160     if ( $error ) {
161       $dbh->rollback if $oldAutoCommit;
162       return $error;
163     }
164
165   }
166
167   # add export_svc records
168   my $slice = 100/scalar(@exportnums) if @exportnums;
169   my $done = 0;
170   foreach my $exportnum ( @exportnums ) {
171     my $export_svc = new FS::export_svc ( {
172       'exportnum' => $exportnum,
173       'svcpart'   => $self->svcpart,
174     } );
175     $error = $export_svc->insert($job, $slice*$done++, $slice);
176     if ( $error ) {
177       $dbh->rollback if $oldAutoCommit;
178       return $error;
179     }
180   }
181
182   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
183
184   '';
185 }
186
187 =item delete
188
189 Currently unimplemented.  Set the "disabled" field instead.
190
191 =cut
192
193 sub delete {
194   return "Can't (yet?) delete service definitions.";
195 # check & make sure the svcpart isn't in cust_svc or pkg_svc (in any packages)?
196 }
197
198 =item replace OLD_RECORD [ '1.3-COMPAT' [ , EXTRA_FIELDS_ARRAYREF [ , EXPORTNUMS_HASHREF [ , JOB ] ] ] ]
199
200 Replaces OLD_RECORD with this one in the database.  If there is an error,
201 returns the error, otherwise returns false.
202
203 TODOC: 1.3-COMPAT
204
205 TODOC: EXTRA_FIELDS_ARRAYREF (same as insert method)
206
207 TODOC: JOB
208
209 =cut
210
211 sub replace {
212   my ( $new, $old ) = ( shift, shift );
213   my $compat = '';
214   my @fields = ();
215   my $exportnums;
216   my $job = '';
217   if ( @_ && $_[0] eq '1.3-COMPAT' ) {
218     shift;
219     $compat = '1.3';
220     @fields = @{shift(@_)} if @_;
221     $exportnums = @_ ? shift : '';
222     $job = shift if @_;
223   } else {
224     return 'non-1.3-COMPAT interface not yet written';
225     #not yet implemented
226   }
227
228   return "Can't change svcdb for an existing service definition!"
229     unless $old->svcdb eq $new->svcdb;
230
231   local $SIG{HUP} = 'IGNORE';
232   local $SIG{INT} = 'IGNORE';
233   local $SIG{QUIT} = 'IGNORE';
234   local $SIG{TERM} = 'IGNORE';
235   local $SIG{TSTP} = 'IGNORE';
236   local $SIG{PIPE} = 'IGNORE';
237
238   my $oldAutoCommit = $FS::UID::AutoCommit;
239   local $FS::UID::AutoCommit = 0;
240   my $dbh = dbh;
241
242   my $error = $new->SUPER::replace( $old );
243   if ( $error ) {
244     $dbh->rollback if $oldAutoCommit;
245     return $error;
246   }
247
248   if ( $compat eq '1.3' ) {
249
250    # maintain part_svc_column records
251
252     my $svcdb = $new->svcdb;
253     foreach my $field (
254       grep { $_ ne 'svcnum'
255              && defined( $new->getfield($svcdb.'__'.$_.'_flag') )
256            } (fields($svcdb),@fields)
257     ) {
258       my $part_svc_column = $new->part_svc_column($field);
259       my $previous = qsearchs('part_svc_column', {
260         'svcpart'    => $new->svcpart,
261         'columnname' => $field,
262       } );
263
264       my $flag = $new->getfield($svcdb.'__'.$field.'_flag');
265       #if ( uc($flag) =~ /^([DFMAX])$/ ) {
266       if ( uc($flag) =~ /^([A-Z])$/ ) { #part_svc_column will test it
267         $part_svc_column->setfield('columnflag', $1);
268         $part_svc_column->setfield('columnvalue',
269           $new->getfield($svcdb.'__'.$field)
270         );
271         if ( $previous ) {
272           $error = $part_svc_column->replace($previous);
273         } else {
274           $error = $part_svc_column->insert;
275         }
276       } else {
277         $error = $previous ? $previous->delete : '';
278       }
279       if ( $error ) {
280         $dbh->rollback if $oldAutoCommit;
281         return $error;
282       }
283     }
284
285     # maintain export_svc records
286
287     if ( $exportnums ) {
288
289       #false laziness w/ edit/process/agent_type.cgi
290       my @new_export_svc = ();
291       foreach my $part_export ( qsearch('part_export', {}) ) {
292         my $exportnum = $part_export->exportnum;
293         my $hashref = {
294           'exportnum' => $exportnum,
295           'svcpart'   => $new->svcpart,
296         };
297         my $export_svc = qsearchs('export_svc', $hashref);
298
299         if ( $export_svc && ! $exportnums->{$exportnum} ) {
300           $error = $export_svc->delete;
301           if ( $error ) {
302             $dbh->rollback if $oldAutoCommit;
303             return $error;
304           }
305         } elsif ( ! $export_svc && $exportnums->{$exportnum} ) {
306           push @new_export_svc, new FS::export_svc ( $hashref );
307         }
308
309       }
310
311       my $slice = 100/scalar(@new_export_svc) if @new_export_svc;
312       my $done = 0;
313       foreach my $export_svc (@new_export_svc) {
314         $error = $export_svc->insert($job, $slice*$done++, $slice);
315         if ( $error ) {
316           $dbh->rollback if $oldAutoCommit;
317           return $error;
318         }
319         if ( $job ) {
320           $error = $job->update_statustext( int( $slice * $done ) );
321           if ( $error ) {
322             $dbh->rollback if $oldAutoCommit;
323             return $error;
324           }
325         }
326       }
327
328     }
329
330   } else {
331     $dbh->rollback if $oldAutoCommit;
332     return 'non-1.3-COMPAT interface not yet written';
333     #not yet implemented
334   }
335
336   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
337
338   '';
339 }
340
341 =item check
342
343 Checks all fields to make sure this is a valid service definition.  If there is
344 an error, returns the error, otherwise returns false.  Called by the insert
345 and replace methods.
346
347 =cut
348
349 sub check {
350   my $self = shift;
351
352   my $error;
353   $error=
354     $self->ut_numbern('svcpart')
355     || $self->ut_text('svc')
356     || $self->ut_alpha('svcdb')
357     || $self->ut_enum('disabled', [ '', 'Y' ] )
358   ;
359   return $error if $error;
360
361   my @fields = eval { fields( $self->svcdb ) }; #might die
362   return "Unknown svcdb: ". $self->svcdb. " (Error: $@)"
363     unless @fields;
364
365   $self->SUPER::check;
366 }
367
368 =item part_svc_column COLUMNNAME
369
370 Returns the part_svc_column object (see L<FS::part_svc_column>) for the given
371 COLUMNNAME, or a new part_svc_column object if none exists.
372
373 =cut
374
375 sub part_svc_column {
376   my( $self, $columnname) = @_;
377   $self->svcpart &&
378     qsearchs('part_svc_column',  {
379                                    'svcpart'    => $self->svcpart,
380                                    'columnname' => $columnname,
381                                  }
382   ) or new FS::part_svc_column {
383                                  'svcpart'    => $self->svcpart,
384                                  'columnname' => $columnname,
385                                };
386 }
387
388 =item all_part_svc_column
389
390 =cut
391
392 sub all_part_svc_column {
393   my $self = shift;
394   qsearch('part_svc_column', { 'svcpart' => $self->svcpart } );
395 }
396
397 =item part_export [ EXPORTTYPE ]
398
399 Returns a list of all exports (see L<FS::part_export>) for this service, or,
400 if an export type is specified, only returns exports of the given type.
401
402 =cut
403
404 sub part_export {
405   my $self = shift;
406   my %search;
407   $search{'exporttype'} = shift if @_;
408   map { qsearchs('part_export', { 'exportnum' => $_->exportnum, %search } ) }
409     qsearch('export_svc', { 'svcpart' => $self->svcpart } );
410 }
411
412 =item part_export_usage
413
414 Returns a list of any exports (see L<FS::part_export>) for this service that
415 are capable of reporting usage information.
416
417 =cut
418
419 sub part_export_usage {
420   my $self = shift;
421   grep $_->can('usage_sessions'), $self->part_export;
422 }
423
424 =item cust_svc [ PKGPART ] 
425
426 Returns a list of associated customer services (FS::cust_svc records).
427
428 If a PKGPART is specified, returns the customer services which are contained
429 within packages of that type (see L<FS::part_pkg>).  If PKGPARTis specified as
430 B<0>, returns unlinked customer services.
431
432 =cut
433
434 sub cust_svc {
435   my $self = shift;
436
437   my $hashref = { 'svcpart' => $self->svcpart };
438
439   my( $addl_from, $extra_sql ) = ( '', '' );
440   if ( @_ ) {
441     my $pkgpart = shift;
442     if ( $pkgpart =~ /^(\d+)$/ ) {
443       $addl_from = 'LEFT JOIN cust_pkg USING ( pkgnum )';
444       $extra_sql = "AND pkgpart = $1";
445     } elsif ( $pkgpart eq '0' ) {
446       $hashref->{'pkgnum'} = '';
447     }
448   }
449
450   qsearch({
451     'table'     => 'cust_svc',
452     'addl_from' => $addl_from,
453     'hashref'   => $hashref,
454     'extra_sql' => $extra_sql,
455   });
456 }
457
458 =item num_cust_svc [ PKGPART ] 
459
460 Returns the number of associated customer services (FS::cust_svc records).
461
462 If a PKGPART is specified, returns the number of customer services which are
463 contained within packages of that type (see L<FS::part_pkg>).  If PKGPART
464 is specified as B<0>, returns the number of unlinked customer services.
465
466 =cut
467
468 sub num_cust_svc {
469   my $self = shift;
470
471   my @param = ( $self->svcpart );
472
473   my( $join, $and ) = ( '', '' );
474   if ( @_ ) {
475     my $pkgpart = shift;
476     if ( $pkgpart ) {
477       $join = 'LEFT JOIN cust_pkg USING ( pkgnum )';
478       $and = 'AND pkgpart = ?';
479       push @param, $pkgpart;
480     } elsif ( $pkgpart eq '0' ) {
481       $and = 'AND pkgnum IS NULL';
482     }
483   }
484
485   my $sth = dbh->prepare(
486     "SELECT COUNT(*) FROM cust_svc $join WHERE svcpart = ? $and"
487   ) or die dbh->errstr;
488   $sth->execute(@param)
489     or die $sth->errstr;
490   $sth->fetchrow_arrayref->[0];
491 }
492
493 =item svc_x
494
495 Returns a list of associated FS::svc_* records.
496
497 =cut
498
499 sub svc_x {
500   my $self = shift;
501   map { $_->svc_x } $self->cust_svc;
502 }
503
504 =back
505
506 =head1 CLASS METHODS
507
508 =over 4
509
510 =cut
511
512 my $svc_defs;
513 sub _svc_defs {
514
515   return $svc_defs if $svc_defs; #cache
516
517   my $conf = new FS::Conf;
518
519   #false laziness w/part_pkg.pm::plan_info
520
521   my %info;
522   foreach my $INC ( @INC ) {
523     warn "globbing $INC/FS/svc_*.pm\n" if $DEBUG;
524     foreach my $file ( glob("$INC/FS/svc_*.pm") ) {
525
526       warn "attempting to load service table info from $file\n" if $DEBUG;
527       $file =~ /\/(\w+)\.pm$/ or do {
528         warn "unrecognized file in $INC/FS/: $file\n";
529         next;
530       };
531       my $mod = $1;
532
533       if ( $mod =~ /^svc_[A-Z]/ or $mod =~ /^svc_acct_pop$/ ) {
534         warn "skipping FS::$mod" if $DEBUG;
535         next;
536       }
537
538       eval "use FS::$mod;";
539       if ( $@ ) {
540         die "error using FS::$mod (skipping): $@\n" if $@;
541         next;
542       }
543       unless ( UNIVERSAL::can("FS::$mod", 'table_info') ) {
544         warn "FS::$mod has no table_info method; skipping";
545         next;
546       }
547
548       my $info = "FS::$mod"->table_info;
549       unless ( keys %$info ) {
550         warn "FS::$mod->table_info doesn't return info, skipping\n";
551         next;
552       }
553       warn "got info from FS::$mod: $info\n" if $DEBUG;
554       if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
555         warn "skipping disabled service FS::$mod" if $DEBUG;
556         next;
557       }
558       $info{$mod} = $info;
559     }
560   }
561
562   tie my %svc_defs, 'Tie::IxHash', 
563     map  { $_ => $info{$_}->{'fields'} }
564     sort { $info{$a}->{'display_weight'} <=> $info{$b}->{'display_weight'} }
565     keys %info,
566   ;
567   
568   # yuck.  maybe this won't be so bad when virtual fields become real fields
569   my %vfields;
570   foreach my $svcdb (grep dbdef->table($_), keys %svc_defs ) {
571     eval "use FS::$svcdb;";
572     my $self = "FS::$svcdb"->new;
573     $vfields{$svcdb} = {};
574     foreach my $field ($self->virtual_fields) { # svc_Common::virtual_fields with a null svcpart returns all of them
575       my $pvf = $self->pvf($field);
576       my @list = $pvf->list;
577       if (scalar @list) {
578         $svc_defs{$svcdb}->{$field} = { desc        => $pvf->label,
579                                         type        => 'select',
580                                         select_list => \@list };
581       } else {
582         $svc_defs{$svcdb}->{$field} = $pvf->label;
583       } #endif
584       $vfields{$svcdb}->{$field} = $pvf;
585       warn "\$vfields{$svcdb}->{$field} = $pvf"
586         if $DEBUG;
587     } #next $field
588   } #next $svcdb
589   
590   $svc_defs = \%svc_defs; #cache
591   
592 }
593
594 =item svc_tables
595
596 Returns a list of all svc_ tables.
597
598 =cut
599
600 sub svc_tables {
601   my $class = shift;
602   my $svc_defs = $class->_svc_defs;
603   grep { defined( dbdef->table($_) ) } keys %$svc_defs;
604 }
605
606 =item svc_table_fields TABLE
607
608 Given a table name, returns a hashref of field names.  The field names
609 returned are those with additional (service-definition related) information,
610 not necessarily all database fields of the table.  Pseudo-fields may also
611 be returned (i.e. svc_acct.usergroup).
612
613 Each value of the hashref is another hashref, which can have one or more of
614 the following keys:
615
616 =over 4
617
618 =item label - Description of the field
619
620 =item def_label - Optional description of the field in the context of service definitions
621
622 =item type - Currently "text", "select", "disabled", or "radius_usergroup_selector"
623
624 =item disable_default - This field should not allow a default value in service definitions
625
626 =item disable_fixed - This field should not allow a fixed value in service definitions
627
628 =item disable_inventory - This field should not allow inventory values in service definitions
629
630 =item select_list - If type is "text", this can be a listref of possible values.
631
632 =item select_table - An alternative to select_list, this defines a database table with the possible choices.
633
634 =item select_key - Used with select_table, this is the field name of keys
635
636 =item select_label - Used with select_table, this is the field name of labels
637
638 =back
639
640 =cut
641
642 #maybe this should move and be a class method in svc_Common.pm
643 sub svc_table_fields {
644   my($class, $table) = @_;
645   my $svc_defs = $class->_svc_defs;
646   my $def = $svc_defs->{$table};
647
648   foreach ( grep !ref($def->{$_}), keys %$def ) {
649
650     #normalize the shortcut in %info hash
651     $def->{$_} = { 'label' => $def->{$_} };
652
653     $def->{$_}{'type'} ||= 'text';
654
655   }
656
657   $def;
658 }
659
660 =back
661
662 =head1 SUBROUTINES
663
664 =over 4
665
666 =item process
667
668 Job-queue processor for web interface adds/edits
669
670 =cut
671
672 use Storable qw(thaw);
673 use Data::Dumper;
674 use MIME::Base64;
675 sub process {
676   my $job = shift;
677
678   my $param = thaw(decode_base64(shift));
679   warn Dumper($param) if $DEBUG;
680
681   my $old = qsearchs('part_svc', { 'svcpart' => $param->{'svcpart'} }) 
682     if $param->{'svcpart'};
683
684   $param->{'svc_acct__usergroup'} =
685     ref($param->{'svc_acct__usergroup'})
686       ? join(',', @{$param->{'svc_acct__usergroup'}} )
687       : $param->{'svc_acct__usergroup'};
688   
689   my $new = new FS::part_svc ( {
690     map {
691       $_ => $param->{$_};
692   #  } qw(svcpart svc svcdb)
693     } ( fields('part_svc'),
694         map { my $svcdb = $_;
695               my @fields = fields($svcdb);
696               push @fields, 'usergroup' if $svcdb eq 'svc_acct'; #kludge
697
698               map {
699                     if ( $param->{ $svcdb.'__'.$_.'_flag' } =~ /^[MA]$/ ) {
700                       $param->{ $svcdb.'__'.$_ } =
701                         delete( $param->{ $svcdb.'__'.$_.'_classnum' } );
702                     }
703                     if ( $param->{ $svcdb.'__'.$_.'_flag' } =~ /^S$/ ) {
704                       $param->{ $svcdb.'__'.$_} =
705                         ref($param->{ $svcdb.'__'.$_})
706                           ? join(',', @{$param->{ $svcdb.'__'.$_ }} )
707                           : $param->{ $svcdb.'__'.$_ };
708                     }
709                     ( $svcdb.'__'.$_, $svcdb.'__'.$_.'_flag' );
710                   }
711                   @fields;
712
713             } FS::part_svc->svc_tables()
714       )
715   } );
716   
717   my %exportnums =
718     map { $_->exportnum => ( $param->{'exportnum'.$_->exportnum} || '') }
719         qsearch('part_export', {} );
720
721   my $error;
722   if ( $param->{'svcpart'} ) {
723     $error = $new->replace( $old,
724                             '1.3-COMPAT',
725                             [ 'usergroup' ],
726                             \%exportnums,
727                             $job
728                           );
729   } else {
730     $error = $new->insert( [ 'usergroup' ],
731                            \%exportnums,
732                            $job,
733                          );
734     $param->{'svcpart'} = $new->getfield('svcpart');
735   }
736
737   die "$error\n" if $error;
738 }
739
740 =item process_bulk_cust_svc
741
742 Job-queue processor for web interface bulk customer service changes
743
744 =cut
745
746 use Storable qw(thaw);
747 use Data::Dumper;
748 use MIME::Base64;
749 sub process_bulk_cust_svc {
750   my $job = shift;
751
752   my $param = thaw(decode_base64(shift));
753   warn Dumper($param) if $DEBUG;
754
755   my $old_part_svc =
756     qsearchs('part_svc', { 'svcpart' => $param->{'old_svcpart'} } );
757
758   die "Must select a new service definition\n" unless $param->{'new_svcpart'};
759
760   #the rest should be abstracted out to to its own subroutine?
761
762   local $SIG{HUP} = 'IGNORE';
763   local $SIG{INT} = 'IGNORE';
764   local $SIG{QUIT} = 'IGNORE';
765   local $SIG{TERM} = 'IGNORE';
766   local $SIG{TSTP} = 'IGNORE';
767   local $SIG{PIPE} = 'IGNORE';
768
769   my $oldAutoCommit = $FS::UID::AutoCommit;
770   local $FS::UID::AutoCommit = 0;
771   my $dbh = dbh;
772
773   local( $FS::cust_svc::ignore_quantity ) = 1;
774
775   my $total = $old_part_svc->num_cust_svc( $param->{'pkgpart'} );
776
777   my $n = 0;
778   foreach my $old_cust_svc ( $old_part_svc->cust_svc( $param->{'pkgpart'} ) ) {
779
780     my $new_cust_svc = new FS::cust_svc { $old_cust_svc->hash };
781
782     $new_cust_svc->svcpart( $param->{'new_svcpart'} );
783     my $error = $new_cust_svc->replace($old_cust_svc);
784     if ( $error ) {
785       $dbh->rollback if $oldAutoCommit;
786       die "$error\n" if $error;
787     }
788
789     $error = $job->update_statustext( int( 100 * ++$n / $total ) );
790     if ( $error ) {
791       $dbh->rollback if $oldAutoCommit;
792       die $error if $error;
793     }
794
795   }
796
797   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
798
799   '';
800
801 }
802
803 =head1 BUGS
804
805 Delete is unimplemented.
806
807 The list of svc_* tables is no longer hardcoded, but svc_acct_pop is skipped
808 as a special case until it is renamed.
809
810 all_part_svc_column methods should be documented
811
812 =head1 SEE ALSO
813
814 L<FS::Record>, L<FS::part_svc_column>, L<FS::part_pkg>, L<FS::pkg_svc>,
815 L<FS::cust_svc>, L<FS::svc_acct>, L<FS::svc_forward>, L<FS::svc_domain>,
816 schema.html from the base documentation.
817
818 =cut
819
820 1;
821