make sure default RADIUS groups don't override ones for existing records
[freeside.git] / FS / FS / svc_Common.pm
1 package FS::svc_Common;
2
3 use strict;
4 use vars qw( @ISA $noexport_hack $DEBUG );
5 use Carp;
6 use FS::Record qw( qsearch qsearchs fields dbh );
7 use FS::cust_main_Mixin;
8 use FS::cust_svc;
9 use FS::part_svc;
10 use FS::queue;
11 use FS::cust_main;
12 use FS::inventory_item;
13 use FS::inventory_class;
14
15 @ISA = qw( FS::cust_main_Mixin FS::Record );
16
17 $DEBUG = 1;
18
19 =head1 NAME
20
21 FS::svc_Common - Object method for all svc_ records
22
23 =head1 SYNOPSIS
24
25 use FS::svc_Common;
26
27 @ISA = qw( FS::svc_Common );
28
29 =head1 DESCRIPTION
30
31 FS::svc_Common is intended as a base class for table-specific classes to
32 inherit from, i.e. FS::svc_acct.  FS::svc_Common inherits from FS::Record.
33
34 =head1 METHODS
35
36 =over 4
37
38 =cut
39
40 sub new {
41   my $proto = shift;
42   my $class = ref($proto) || $proto;
43   my $self = {};
44   bless ($self, $class);
45
46   unless ( defined ( $self->table ) ) {
47     $self->{'Table'} = shift;
48     carp "warning: FS::Record::new called with table name ". $self->{'Table'};
49   }
50   
51   #$self->{'Hash'} = shift;
52   my $newhash = shift;
53   $self->{'Hash'} = { map { $_ => $newhash->{$_} } qw(svcnum svcpart) };
54
55   $self->setdefault( $self->_fieldhandlers )
56     unless $self->svcnum;
57
58   $self->{'Hash'}{$_} = $newhash->{$_}
59     foreach grep { defined($newhash->{$_}) && length($newhash->{$_}) }
60                  keys %$newhash;
61
62   foreach my $field ( grep !defined($self->{'Hash'}{$_}), $self->fields ) { 
63     $self->{'Hash'}{$field}='';
64   }
65
66   $self->_rebless if $self->can('_rebless');
67
68   $self->{'modified'} = 0;
69
70   $self->_cache($self->{'Hash'}, shift) if $self->can('_cache') && @_;
71
72   $self;
73 }
74
75 #empty default
76 sub _fieldhandlers { {}; }
77
78 sub virtual_fields {
79
80   # This restricts the fields based on part_svc_column and the svcpart of 
81   # the service.  There are four possible cases:
82   # 1.  svcpart passed as part of the svc_x hash.
83   # 2.  svcpart fetched via cust_svc based on svcnum.
84   # 3.  No svcnum or svcpart.  In this case, return ALL the fields with 
85   #     dbtable eq $self->table.
86   # 4.  Called via "fields('svc_acct')" or something similar.  In this case
87   #     there is no $self object.
88
89   my $self = shift;
90   my $svcpart;
91   my @vfields = $self->SUPER::virtual_fields;
92
93   return @vfields unless (ref $self); # Case 4
94
95   if ($self->svcpart) { # Case 1
96     $svcpart = $self->svcpart;
97   } elsif ( $self->svcnum
98             && qsearchs('cust_svc',{'svcnum'=>$self->svcnum} )
99           ) { #Case 2
100     $svcpart = $self->cust_svc->svcpart;
101   } else { # Case 3
102     $svcpart = '';
103   }
104
105   if ($svcpart) { #Cases 1 and 2
106     my %flags = map { $_->columnname, $_->columnflag } (
107         qsearch ('part_svc_column', { svcpart => $svcpart } )
108       );
109     return grep { not ($flags{$_} eq 'X') } @vfields;
110   } else { # Case 3
111     return @vfields;
112   } 
113   return ();
114 }
115
116 =item check
117
118 Checks the validity of fields in this record.
119
120 At present, this does nothing but call FS::Record::check (which, in turn, 
121 does nothing but run virtual field checks).
122
123 =cut
124
125 sub check {
126   my $self = shift;
127   $self->SUPER::check;
128 }
129
130 =item insert [ , OPTION => VALUE ... ]
131
132 Adds this record to the database.  If there is an error, returns the error,
133 otherwise returns false.
134
135 The additional fields pkgnum and svcpart (see L<FS::cust_svc>) should be 
136 defined.  An FS::cust_svc record will be created and inserted.
137
138 Currently available options are: I<jobnums>, I<child_objects> and
139 I<depend_jobnum>.
140
141 If I<jobnum> is set to an array reference, the jobnums of any export jobs will
142 be added to the referenced array.
143
144 If I<child_objects> is set to an array reference of FS::tablename objects (for
145 example, FS::acct_snarf objects), they will have their svcnum field set and
146 will be inserted after this record, but before any exports are run.  Each
147 element of the array can also optionally be a two-element array reference
148 containing the child object and the name of an alternate field to be filled in
149 with the newly-inserted svcnum, for example C<[ $svc_forward, 'srcsvc' ]>
150
151 If I<depend_jobnum> is set (to a scalar jobnum or an array reference of
152 jobnums), all provisioning jobs will have a dependancy on the supplied
153 jobnum(s) (they will not run until the specific job(s) complete(s)).
154
155 =cut
156
157 sub insert {
158   my $self = shift;
159   my %options = @_;
160   warn "FS::svc_Common::insert called with options ".
161      join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
162   if $DEBUG;
163
164   my @jobnums = ();
165   local $FS::queue::jobnums = \@jobnums;
166   warn "FS::svc_Common::insert: set \$FS::queue::jobnums to $FS::queue::jobnums"
167     if $DEBUG;
168   my $objects = $options{'child_objects'} || [];
169   my $depend_jobnums = $options{'depend_jobnum'} || [];
170   $depend_jobnums = [ $depend_jobnums ] unless ref($depend_jobnums);
171   my $error;
172
173   local $SIG{HUP} = 'IGNORE';
174   local $SIG{INT} = 'IGNORE';
175   local $SIG{QUIT} = 'IGNORE';
176   local $SIG{TERM} = 'IGNORE';
177   local $SIG{TSTP} = 'IGNORE';
178   local $SIG{PIPE} = 'IGNORE';
179
180   my $oldAutoCommit = $FS::UID::AutoCommit;
181   local $FS::UID::AutoCommit = 0;
182   my $dbh = dbh;
183
184   $error = $self->check;
185   return $error if $error;
186
187   my $svcnum = $self->svcnum;
188   my $cust_svc = $svcnum ? qsearchs('cust_svc',{'svcnum'=>$self->svcnum}) : '';
189   #unless ( $svcnum ) {
190   if ( !$svcnum or !$cust_svc ) {
191     $cust_svc = new FS::cust_svc ( {
192       #hua?# 'svcnum'  => $svcnum,
193       'svcnum'  => $self->svcnum,
194       'pkgnum'  => $self->pkgnum,
195       'svcpart' => $self->svcpart,
196     } );
197     $error = $cust_svc->insert;
198     if ( $error ) {
199       $dbh->rollback if $oldAutoCommit;
200       return $error;
201     }
202     $svcnum = $self->svcnum($cust_svc->svcnum);
203   } else {
204     #$cust_svc = qsearchs('cust_svc',{'svcnum'=>$self->svcnum});
205     unless ( $cust_svc ) {
206       $dbh->rollback if $oldAutoCommit;
207       return "no cust_svc record found for svcnum ". $self->svcnum;
208     }
209     $self->pkgnum($cust_svc->pkgnum);
210     $self->svcpart($cust_svc->svcpart);
211   }
212
213   $error = $self->set_auto_inventory;
214   if ( $error ) {
215     $dbh->rollback if $oldAutoCommit;
216     return $error;
217   }
218
219   $error = $self->SUPER::insert;
220   if ( $error ) {
221     $dbh->rollback if $oldAutoCommit;
222     return $error;
223   }
224
225   foreach my $object ( @$objects ) {
226     my($field, $obj);
227     if ( ref($object) eq 'ARRAY' ) {
228       ($obj, $field) = @$object;
229     } else {
230       $obj = $object;
231       $field = 'svcnum';
232     }
233     $obj->$field($self->svcnum);
234     $error = $obj->insert;
235     if ( $error ) {
236       $dbh->rollback if $oldAutoCommit;
237       return $error;
238     }
239   }
240
241   #new-style exports!
242   unless ( $noexport_hack ) {
243
244     warn "FS::svc_Common::insert: \$FS::queue::jobnums is $FS::queue::jobnums"
245       if $DEBUG;
246
247     foreach my $part_export ( $self->cust_svc->part_svc->part_export ) {
248       my $error = $part_export->export_insert($self);
249       if ( $error ) {
250         $dbh->rollback if $oldAutoCommit;
251         return "exporting to ". $part_export->exporttype.
252                " (transaction rolled back): $error";
253       }
254     }
255
256     foreach my $depend_jobnum ( @$depend_jobnums ) {
257       warn "inserting dependancies on supplied job $depend_jobnum\n"
258         if $DEBUG;
259       foreach my $jobnum ( @jobnums ) {
260         my $queue = qsearchs('queue', { 'jobnum' => $jobnum } );
261         warn "inserting dependancy for job $jobnum on $depend_jobnum\n"
262           if $DEBUG;
263         my $error = $queue->depend_insert($depend_jobnum);
264         if ( $error ) {
265           $dbh->rollback if $oldAutoCommit;
266           return "error queuing job dependancy: $error";
267         }
268       }
269     }
270
271   }
272
273   if ( exists $options{'jobnums'} ) {
274     push @{ $options{'jobnums'} }, @jobnums;
275   }
276
277   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
278
279   '';
280 }
281
282 =item delete
283
284 Deletes this account from the database.  If there is an error, returns the
285 error, otherwise returns false.
286
287 The corresponding FS::cust_svc record will be deleted as well.
288
289 =cut
290
291 sub delete {
292   my $self = shift;
293   my $error;
294
295   local $SIG{HUP} = 'IGNORE';
296   local $SIG{INT} = 'IGNORE';
297   local $SIG{QUIT} = 'IGNORE';
298   local $SIG{TERM} = 'IGNORE';
299   local $SIG{TSTP} = 'IGNORE';
300   local $SIG{PIPE} = 'IGNORE';
301
302   my $svcnum = $self->svcnum;
303
304   my $oldAutoCommit = $FS::UID::AutoCommit;
305   local $FS::UID::AutoCommit = 0;
306   my $dbh = dbh;
307
308   $error = $self->SUPER::delete;
309   return $error if $error;
310
311   #new-style exports!
312   unless ( $noexport_hack ) {
313     foreach my $part_export ( $self->cust_svc->part_svc->part_export ) {
314       $error = $part_export->export_delete($self);
315       if ( $error ) {
316         $dbh->rollback if $oldAutoCommit;
317         return "exporting to ". $part_export->exporttype.
318                " (transaction rolled back): $error";
319       }
320     }
321   }
322
323   $error = $self->return_inventory;
324   if ( $error ) {
325     $dbh->rollback if $oldAutoCommit;
326     return "error returning inventory: $error";
327   }
328
329   my $cust_svc = $self->cust_svc;
330   $error = $cust_svc->delete;
331   if ( $error ) {
332     $dbh->rollback if $oldAutoCommit;
333     return $error;
334   }
335
336   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
337
338   '';
339 }
340
341 =item replace OLD_RECORD
342
343 Replaces OLD_RECORD with this one.  If there is an error, returns the error,
344 otherwise returns false.
345
346 =cut
347
348 sub replace {
349   my ($new, $old) = (shift, shift);
350
351   local $SIG{HUP} = 'IGNORE';
352   local $SIG{INT} = 'IGNORE';
353   local $SIG{QUIT} = 'IGNORE';
354   local $SIG{TERM} = 'IGNORE';
355   local $SIG{TSTP} = 'IGNORE';
356   local $SIG{PIPE} = 'IGNORE';
357
358   my $oldAutoCommit = $FS::UID::AutoCommit;
359   local $FS::UID::AutoCommit = 0;
360   my $dbh = dbh;
361
362   my $error = $new->set_auto_inventory;
363   if ( $error ) {
364     $dbh->rollback if $oldAutoCommit;
365     return $error;
366   }
367
368   $error = $new->SUPER::replace($old);
369   if ($error) {
370     $dbh->rollback if $oldAutoCommit;
371     return $error;
372   }
373
374   #new-style exports!
375   unless ( $noexport_hack ) {
376
377     #not quite false laziness, but same pattern as FS::svc_acct::replace and
378     #FS::part_export::sqlradius::_export_replace.  List::Compare or something
379     #would be useful but too much of a pain in the ass to deploy
380
381     my @old_part_export = $old->cust_svc->part_svc->part_export;
382     my %old_exportnum = map { $_->exportnum => 1 } @old_part_export;
383     my @new_part_export = 
384       $new->svcpart
385         ? qsearchs('part_svc', { svcpart=>$new->svcpart } )->part_export
386         : $new->cust_svc->part_svc->part_export;
387     my %new_exportnum = map { $_->exportnum => 1 } @new_part_export;
388
389     foreach my $delete_part_export (
390       grep { ! $new_exportnum{$_->exportnum} } @old_part_export
391     ) {
392       my $error = $delete_part_export->export_delete($old);
393       if ( $error ) {
394         $dbh->rollback if $oldAutoCommit;
395         return "error deleting, export to ". $delete_part_export->exporttype.
396                " (transaction rolled back): $error";
397       }
398     }
399
400     foreach my $replace_part_export (
401       grep { $old_exportnum{$_->exportnum} } @new_part_export
402     ) {
403       my $error = $replace_part_export->export_replace($new,$old);
404       if ( $error ) {
405         $dbh->rollback if $oldAutoCommit;
406         return "error exporting to ". $replace_part_export->exporttype.
407                " (transaction rolled back): $error";
408       }
409     }
410
411     foreach my $insert_part_export (
412       grep { ! $old_exportnum{$_->exportnum} } @new_part_export
413     ) {
414       my $error = $insert_part_export->export_insert($new);
415       if ( $error ) {
416         $dbh->rollback if $oldAutoCommit;
417         return "error inserting export to ". $insert_part_export->exporttype.
418                " (transaction rolled back): $error";
419       }
420     }
421
422   }
423
424   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
425   '';
426 }
427
428
429 =item setfixed
430
431 Sets any fixed fields for this service (see L<FS::part_svc>).  If there is an
432 error, returns the error, otherwise returns the FS::part_svc object (use ref()
433 to test the return).  Usually called by the check method.
434
435 =cut
436
437 sub setfixed {
438   my $self = shift;
439   $self->setx('F', @_);
440 }
441
442 =item setdefault
443
444 Sets all fields to their defaults (see L<FS::part_svc>), overriding their
445 current values.  If there is an error, returns the error, otherwise returns
446 the FS::part_svc object (use ref() to test the return).
447
448 =cut
449
450 sub setdefault {
451   my $self = shift;
452   $self->setx('D', @_ );
453 }
454
455 =item set_default_and_fixed
456
457 =cut
458
459 sub set_default_and_fixed {
460   my $self = shift;
461   $self->setx( [ 'D', 'F' ], @_ );
462 }
463
464 =item setx FLAG | FLAG_ARRAYREF , [ CALLBACK_HASHREF ]
465
466 Sets fields according to the passed in flag or arrayref of flags.
467
468 Optionally, a hashref of field names and callback coderefs can be passed.
469 If a coderef exists for a given field name, instead of setting the field,
470 the coderef is called with the column value (part_svc_column.columnvalue)
471 as the single parameter.
472
473 =cut
474
475 sub setx {
476   my $self = shift;
477   my $x = shift;
478   my @x = ref($x) ? @$x : ($x);
479   my $coderef = scalar(@_) ? shift : $self->_fieldhandlers;
480
481   my $error =
482     $self->ut_numbern('svcnum')
483   ;
484   return $error if $error;
485
486   my $part_svc = $self->part_svc;
487   return "Unkonwn svcpart" unless $part_svc;
488
489   #set default/fixed/whatever fields from part_svc
490
491   foreach my $part_svc_column (
492     grep { my $f = $_->columnflag; grep { $f eq $_ } @x } #columnflag in @x
493     $part_svc->all_part_svc_column
494   ) {
495
496     my $columnname  = $part_svc_column->columnname;
497     my $columnvalue = $part_svc_column->columnvalue;
498
499     $columnvalue = &{ $coderef->{$columnname} }( $self, $columnvalue )
500       if exists( $coderef->{$columnname} );
501     $self->setfield( $columnname, $columnvalue );
502
503   }
504
505  $part_svc;
506
507 }
508
509 sub part_svc {
510   my $self = shift;
511
512   #get part_svc
513   my $svcpart;
514   if ( $self->get('svcpart') ) {
515     $svcpart = $self->get('svcpart');
516   } elsif ( $self->svcnum && qsearchs('cust_svc', {'svcnum'=>$self->svcnum}) ) {
517     my $cust_svc = $self->cust_svc;
518     return "Unknown svcnum" unless $cust_svc; 
519     $svcpart = $cust_svc->svcpart;
520   }
521
522   qsearchs( 'part_svc', { 'svcpart' => $svcpart } );
523
524 }
525
526 =item set_auto_inventory
527
528 Sets any fields which auto-populate from inventory (see L<FS::part_svc>).
529 If there is an error, returns the error, otherwise returns false.
530
531 =cut
532
533 sub set_auto_inventory {
534   my $self = shift;
535
536   my $error =
537     $self->ut_numbern('svcnum')
538   ;
539   return $error if $error;
540
541   my $part_svc = $self->part_svc;
542   return "Unkonwn svcpart" unless $part_svc;
543
544   local $SIG{HUP} = 'IGNORE';
545   local $SIG{INT} = 'IGNORE';
546   local $SIG{QUIT} = 'IGNORE';
547   local $SIG{TERM} = 'IGNORE';
548   local $SIG{TSTP} = 'IGNORE';
549   local $SIG{PIPE} = 'IGNORE';
550
551   my $oldAutoCommit = $FS::UID::AutoCommit;
552   local $FS::UID::AutoCommit = 0;
553   my $dbh = dbh;
554
555   #set default/fixed/whatever fields from part_svc
556   my $table = $self->table;
557   foreach my $field ( grep { $_ ne 'svcnum' } $self->fields ) {
558     my $part_svc_column = $part_svc->part_svc_column($field);
559     if ( $part_svc_column->columnflag eq 'A' && $self->$field() eq '' ) {
560
561       my $classnum = $part_svc_column->columnvalue;
562       my $inventory_item = qsearchs({
563         'table'     => 'inventory_item',
564         'hashref'   => { 'classnum' => $classnum, 
565                          'svcnum'   => '',
566                        },
567         'extra_sql' => 'LIMIT 1 FOR UPDATE',
568       });
569
570       unless ( $inventory_item ) {
571         $dbh->rollback if $oldAutoCommit;
572         my $inventory_class =
573           qsearchs('inventory_class', { 'classnum' => $classnum } );
574         return "Can't find inventory_class.classnum $classnum"
575           unless $inventory_class;
576         return "Out of ". $inventory_class->classname. "s\n"; #Lingua:: BS
577                                                               #for pluralizing
578       }
579
580       $inventory_item->svcnum( $self->svcnum );
581       my $ierror = $inventory_item->replace();
582       if ( $ierror ) {
583         $dbh->rollback if $oldAutoCommit;
584         return "Error provisioning inventory: $ierror";
585         
586       }
587
588       $self->setfield( $field, $inventory_item->item );
589
590     }
591   }
592
593  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
594
595  '';
596
597 }
598
599 =item return_inventory
600
601 =cut
602
603 sub return_inventory {
604   my $self = shift;
605
606   local $SIG{HUP} = 'IGNORE';
607   local $SIG{INT} = 'IGNORE';
608   local $SIG{QUIT} = 'IGNORE';
609   local $SIG{TERM} = 'IGNORE';
610   local $SIG{TSTP} = 'IGNORE';
611   local $SIG{PIPE} = 'IGNORE';
612
613   my $oldAutoCommit = $FS::UID::AutoCommit;
614   local $FS::UID::AutoCommit = 0;
615   my $dbh = dbh;
616
617   foreach my $inventory_item ( $self->inventory_item ) {
618     $inventory_item->svcnum('');
619     my $error = $inventory_item->replace();
620     if ( $error ) {
621       $dbh->rollback if $oldAutoCommit;
622       return "Error returning inventory: $error";
623     }
624   }
625
626   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
627
628   '';
629 }
630
631 =item inventory_item
632
633 Returns the inventory items associated with this svc_ record, as
634 FS::inventory_item objects (see L<FS::inventory_item>.
635
636 =cut
637
638 sub inventory_item {
639   my $self = shift;
640   qsearch({
641     'table'     => 'inventory_item',
642     'hashref'   => { 'svcnum' => $self->svcnum, },
643   });
644 }
645
646 =item cust_svc
647
648 Returns the cust_svc record associated with this svc_ record, as a FS::cust_svc
649 object (see L<FS::cust_svc>).
650
651 =cut
652
653 sub cust_svc {
654   my $self = shift;
655   qsearchs('cust_svc', { 'svcnum' => $self->svcnum } );
656 }
657
658 =item suspend
659
660 Runs export_suspend callbacks.
661
662 =cut
663
664 sub suspend {
665   my $self = shift;
666
667   local $SIG{HUP} = 'IGNORE';
668   local $SIG{INT} = 'IGNORE';
669   local $SIG{QUIT} = 'IGNORE';
670   local $SIG{TERM} = 'IGNORE';
671   local $SIG{TSTP} = 'IGNORE';
672   local $SIG{PIPE} = 'IGNORE';
673
674   my $oldAutoCommit = $FS::UID::AutoCommit;
675   local $FS::UID::AutoCommit = 0;
676   my $dbh = dbh;
677
678   #new-style exports!
679   unless ( $noexport_hack ) {
680     foreach my $part_export ( $self->cust_svc->part_svc->part_export ) {
681       my $error = $part_export->export_suspend($self);
682       if ( $error ) {
683         $dbh->rollback if $oldAutoCommit;
684         return "error exporting to ". $part_export->exporttype.
685                " (transaction rolled back): $error";
686       }
687     }
688   }
689
690   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
691   '';
692
693 }
694
695 =item unsuspend
696
697 Runs export_unsuspend callbacks.
698
699 =cut
700
701 sub unsuspend {
702   my $self = shift;
703
704   local $SIG{HUP} = 'IGNORE';
705   local $SIG{INT} = 'IGNORE';
706   local $SIG{QUIT} = 'IGNORE';
707   local $SIG{TERM} = 'IGNORE';
708   local $SIG{TSTP} = 'IGNORE';
709   local $SIG{PIPE} = 'IGNORE';
710
711   my $oldAutoCommit = $FS::UID::AutoCommit;
712   local $FS::UID::AutoCommit = 0;
713   my $dbh = dbh;
714
715   #new-style exports!
716   unless ( $noexport_hack ) {
717     foreach my $part_export ( $self->cust_svc->part_svc->part_export ) {
718       my $error = $part_export->export_unsuspend($self);
719       if ( $error ) {
720         $dbh->rollback if $oldAutoCommit;
721         return "error exporting to ". $part_export->exporttype.
722                " (transaction rolled back): $error";
723       }
724     }
725   }
726
727   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
728   '';
729
730 }
731
732 =item cancel
733
734 Stub - returns false (no error) so derived classes don't need to define these
735 methods.  Called by the cancel method of FS::cust_pkg (see L<FS::cust_pkg>).
736
737 =cut
738
739 sub cancel { ''; }
740
741 =item clone_suspended
742
743 Constructor used by FS::part_export::_export_suspend fallback.  Stub returning
744 same object for svc_ classes which don't implement a suspension fallback
745 (everything except svc_acct at the moment).  Document better.
746
747 =cut
748
749 sub clone_suspended {
750   shift;
751 }
752
753 =item clone_kludge_unsuspend 
754
755 Constructor used by FS::part_export::_export_unsuspend fallback.  Stub returning
756 same object for svc_ classes which don't implement a suspension fallback
757 (everything except svc_acct at the moment).  Document better.
758
759 =cut
760
761 sub clone_kludge_unsuspend {
762   shift;
763 }
764
765 =back
766
767 =head1 BUGS
768
769 The setfixed method return value.
770
771 =head1 SEE ALSO
772
773 L<FS::Record>, L<FS::cust_svc>, L<FS::part_svc>, L<FS::cust_pkg>, schema.html
774 from the base documentation.
775
776 =cut
777
778 1;
779