rewrite CDRs for forwarded Asterisk calls to be billable, RT#3196
[freeside.git] / FS / FS / cdr.pm
1 package FS::cdr;
2
3 use strict;
4 use vars qw( @ISA @EXPORT_OK $DEBUG );
5 use Exporter;
6 use Tie::IxHash;
7 use Date::Parse;
8 use Date::Format;
9 use Time::Local;
10 use FS::UID qw( dbh );
11 use FS::Conf;
12 use FS::Record qw( qsearch qsearchs );
13 use FS::cdr_type;
14 use FS::cdr_calltype;
15 use FS::cdr_carrier;
16 use FS::cdr_upstream_rate;
17
18 @ISA = qw(FS::Record);
19 @EXPORT_OK = qw( _cdr_date_parser_maker _cdr_min_parser_maker );
20
21 $DEBUG = 0;
22
23 =head1 NAME
24
25 FS::cdr - Object methods for cdr records
26
27 =head1 SYNOPSIS
28
29   use FS::cdr;
30
31   $record = new FS::cdr \%hash;
32   $record = new FS::cdr { 'column' => 'value' };
33
34   $error = $record->insert;
35
36   $error = $new_record->replace($old_record);
37
38   $error = $record->delete;
39
40   $error = $record->check;
41
42 =head1 DESCRIPTION
43
44 An FS::cdr object represents an Call Data Record, typically from a telephony
45 system or provider of some sort.  FS::cdr inherits from FS::Record.  The
46 following fields are currently supported:
47
48 =over 4
49
50 =item acctid - primary key
51
52 =item calldate - Call timestamp (SQL timestamp)
53
54 =item clid - Caller*ID with text
55
56 =item src - Caller*ID number / Source number
57
58 =item dst - Destination extension
59
60 =item dcontext - Destination context
61
62 =item channel - Channel used
63
64 =item dstchannel - Destination channel if appropriate
65
66 =item lastapp - Last application if appropriate
67
68 =item lastdata - Last application data
69
70 =item startdate - Start of call (UNIX-style integer timestamp)
71
72 =item answerdate - Answer time of call (UNIX-style integer timestamp)
73
74 =item enddate - End time of call (UNIX-style integer timestamp)
75
76 =item duration - Total time in system, in seconds
77
78 =item billsec - Total time call is up, in seconds
79
80 =item disposition - What happened to the call: ANSWERED, NO ANSWER, BUSY 
81
82 =item amaflags - What flags to use: BILL, IGNORE etc, specified on a per channel basis like accountcode. 
83
84 =cut
85
86   #ignore the "omit" and "documentation" AMAs??
87   #AMA = Automated Message Accounting. 
88   #default: Sets the system default. 
89   #omit: Do not record calls. 
90   #billing: Mark the entry for billing 
91   #documentation: Mark the entry for documentation.
92
93 =item accountcode - CDR account number to use: account
94
95 =item uniqueid - Unique channel identifier (Unitel/RSLCOM Event ID)
96
97 =item userfield - CDR user-defined field
98
99 =item cdr_type - CDR type - see L<FS::cdr_type> (Usage = 1, S&E = 7, OC&C = 8)
100
101 =item charged_party - Service number to be billed
102
103 =item upstream_currency - Wholesale currency from upstream
104
105 =item upstream_price - Wholesale price from upstream
106
107 =item upstream_rateplanid - Upstream rate plan ID
108
109 =item rated_price - Rated (or re-rated) price
110
111 =item distance - km (need units field?)
112
113 =item islocal - Local - 1, Non Local = 0
114
115 =item calltypenum - Type of call - see L<FS::cdr_calltype>
116
117 =item description - Description (cdr_type 7&8 only) (used for cust_bill_pkg.itemdesc)
118
119 =item quantity - Number of items (cdr_type 7&8 only)
120
121 =item carrierid - Upstream Carrier ID (see L<FS::cdr_carrier>) 
122
123 =cut
124
125 #Telstra =1, Optus = 2, RSL COM = 3
126
127 =item upstream_rateid - Upstream Rate ID
128
129 =item svcnum - Link to customer service (see L<FS::cust_svc>)
130
131 =item freesidestatus - NULL, done (or something)
132
133 =item freesiderewritestatus - NULL, done (or something)
134
135 =item cdrbatch
136
137 =back
138
139 =head1 METHODS
140
141 =over 4
142
143 =item new HASHREF
144
145 Creates a new CDR.  To add the CDR to the database, see L<"insert">.
146
147 Note that this stores the hash reference, not a distinct copy of the hash it
148 points to.  You can ask the object for a copy with the I<hash> method.
149
150 =cut
151
152 # the new method can be inherited from FS::Record, if a table method is defined
153
154 sub table { 'cdr'; }
155
156 =item insert
157
158 Adds this record to the database.  If there is an error, returns the error,
159 otherwise returns false.
160
161 =cut
162
163 # the insert method can be inherited from FS::Record
164
165 =item delete
166
167 Delete this record from the database.
168
169 =cut
170
171 # the delete method can be inherited from FS::Record
172
173 =item replace OLD_RECORD
174
175 Replaces the OLD_RECORD with this one in the database.  If there is an error,
176 returns the error, otherwise returns false.
177
178 =cut
179
180 # the replace method can be inherited from FS::Record
181
182 =item check
183
184 Checks all fields to make sure this is a valid CDR.  If there is
185 an error, returns the error, otherwise returns false.  Called by the insert
186 and replace methods.
187
188 Note: Unlike most types of records, we don't want to "reject" a CDR and we want
189 to process them as quickly as possible, so we allow the database to check most
190 of the data.
191
192 =cut
193
194 sub check {
195   my $self = shift;
196
197 # we don't want to "reject" a CDR like other sorts of input...
198 #  my $error = 
199 #    $self->ut_numbern('acctid')
200 ##    || $self->ut_('calldate')
201 #    || $self->ut_text('clid')
202 #    || $self->ut_text('src')
203 #    || $self->ut_text('dst')
204 #    || $self->ut_text('dcontext')
205 #    || $self->ut_text('channel')
206 #    || $self->ut_text('dstchannel')
207 #    || $self->ut_text('lastapp')
208 #    || $self->ut_text('lastdata')
209 #    || $self->ut_numbern('startdate')
210 #    || $self->ut_numbern('answerdate')
211 #    || $self->ut_numbern('enddate')
212 #    || $self->ut_number('duration')
213 #    || $self->ut_number('billsec')
214 #    || $self->ut_text('disposition')
215 #    || $self->ut_number('amaflags')
216 #    || $self->ut_text('accountcode')
217 #    || $self->ut_text('uniqueid')
218 #    || $self->ut_text('userfield')
219 #    || $self->ut_numbern('cdrtypenum')
220 #    || $self->ut_textn('charged_party')
221 ##    || $self->ut_n('upstream_currency')
222 ##    || $self->ut_n('upstream_price')
223 #    || $self->ut_numbern('upstream_rateplanid')
224 ##    || $self->ut_n('distance')
225 #    || $self->ut_numbern('islocal')
226 #    || $self->ut_numbern('calltypenum')
227 #    || $self->ut_textn('description')
228 #    || $self->ut_numbern('quantity')
229 #    || $self->ut_numbern('carrierid')
230 #    || $self->ut_numbern('upstream_rateid')
231 #    || $self->ut_numbern('svcnum')
232 #    || $self->ut_textn('freesidestatus')
233 #    || $self->ut_textn('freesiderewritestatus')
234 #  ;
235 #  return $error if $error;
236
237   $self->calldate( $self->startdate_sql )
238     if !$self->calldate && $self->startdate;
239
240   #was just for $format eq 'taqua' but can't see the harm... add something to
241   #disable if it becomes a problem
242   if ( $self->duration eq '' && $self->enddate && $self->startdate ) {
243     $self->duration( $self->enddate - $self->startdate  );
244   }
245   if ( $self->billsec eq '' && $self->enddate && $self->answerdate ) {
246     $self->billsec(  $self->enddate - $self->answerdate );
247   } 
248
249   my $conf = new FS::Conf;
250
251   unless ( $self->charged_party ) {
252
253     if ( $conf->exists('cdr-charged_party-accountcode') && $self->accountcode ){
254
255       $self->charged_party( $self->accountcode );
256
257     } else {
258
259       if ( $self->dst =~ /^(\+?1)?8[02-8]{2}/ ) {
260         $self->charged_party($self->dst);
261       } else {
262         $self->charged_party($self->src);
263       }
264
265     }
266
267   }
268
269   #check the foreign keys even?
270   #do we want to outright *reject* the CDR?
271   my $error =
272        $self->ut_numbern('acctid')
273
274   #add a config option to turn these back on if someone needs 'em
275   #
276   #  #Usage = 1, S&E = 7, OC&C = 8
277   #  || $self->ut_foreign_keyn('cdrtypenum',  'cdr_type',     'cdrtypenum' )
278   #
279   #  #the big list in appendix 2
280   #  || $self->ut_foreign_keyn('calltypenum', 'cdr_calltype', 'calltypenum' )
281   #
282   #  # Telstra =1, Optus = 2, RSL COM = 3
283   #  || $self->ut_foreign_keyn('carrierid', 'cdr_carrier', 'carrierid' )
284   ;
285   return $error if $error;
286
287   $self->SUPER::check;
288 }
289
290 =item set_status_and_rated_price STATUS [ RATED_PRICE ]
291
292 Sets the status to the provided string.  If there is an error, returns the
293 error, otherwise returns false.
294
295 =cut
296
297 sub set_status_and_rated_price {
298   my($self, $status, $rated_price) = @_;
299   $self->freesidestatus($status);
300   $self->rated_price($rated_price);
301   $self->replace();
302 }
303
304 =item calldate_unix 
305
306 Parses the calldate in SQL string format and returns a UNIX timestamp.
307
308 =cut
309
310 sub calldate_unix {
311   str2time(shift->calldate);
312 }
313
314 =item startdate_sql
315
316 Parses the startdate in UNIX timestamp format and returns a string in SQL
317 format.
318
319 =cut
320
321 sub startdate_sql {
322   my($sec,$min,$hour,$mday,$mon,$year) = localtime(shift->startdate);
323   $mon++;
324   $year += 1900;
325   "$year-$mon-$mday $hour:$min:$sec";
326 }
327
328 =item cdr_carrier
329
330 Returns the FS::cdr_carrier object associated with this CDR, or false if no
331 carrierid is defined.
332
333 =cut
334
335 my %carrier_cache = ();
336
337 sub cdr_carrier {
338   my $self = shift;
339   return '' unless $self->carrierid;
340   $carrier_cache{$self->carrierid} ||=
341     qsearchs('cdr_carrier', { 'carrierid' => $self->carrierid } );
342 }
343
344 =item carriername 
345
346 Returns the carrier name (see L<FS::cdr_carrier>), or the empty string if
347 no FS::cdr_carrier object is assocated with this CDR.
348
349 =cut
350
351 sub carriername {
352   my $self = shift;
353   my $cdr_carrier = $self->cdr_carrier;
354   $cdr_carrier ? $cdr_carrier->carriername : '';
355 }
356
357 =item cdr_calltype
358
359 Returns the FS::cdr_calltype object associated with this CDR, or false if no
360 calltypenum is defined.
361
362 =cut
363
364 my %calltype_cache = ();
365
366 sub cdr_calltype {
367   my $self = shift;
368   return '' unless $self->calltypenum;
369   $calltype_cache{$self->calltypenum} ||=
370     qsearchs('cdr_calltype', { 'calltypenum' => $self->calltypenum } );
371 }
372
373 =item calltypename 
374
375 Returns the call type name (see L<FS::cdr_calltype>), or the empty string if
376 no FS::cdr_calltype object is assocated with this CDR.
377
378 =cut
379
380 sub calltypename {
381   my $self = shift;
382   my $cdr_calltype = $self->cdr_calltype;
383   $cdr_calltype ? $cdr_calltype->calltypename : '';
384 }
385
386 =item cdr_upstream_rate
387
388 Returns the upstream rate mapping (see L<FS::cdr_upstream_rate>), or the empty
389 string if no FS::cdr_upstream_rate object is associated with this CDR.
390
391 =cut
392
393 sub cdr_upstream_rate {
394   my $self = shift;
395   return '' unless $self->upstream_rateid;
396   qsearchs('cdr_upstream_rate', { 'upstream_rateid' => $self->upstream_rateid })
397     or '';
398 }
399
400 =item _convergent_format COLUMN [ COUNTRYCODE ]
401
402 Returns the number in COLUMN formatted as follows:
403
404 If the country code does not match COUNTRYCODE (default "61"), it is returned
405 unchanged.
406
407 If the country code does match COUNTRYCODE (default "61"), it is removed.  In
408 addiiton, "0" is prepended unless the number starts with 13, 18 or 19. (???)
409
410 =cut
411
412 sub _convergent_format {
413   my( $self, $field ) = ( shift, shift );
414   my $countrycode = scalar(@_) ? shift : '61'; #+61 = australia
415   #my $number = $self->$field();
416   my $number = $self->get($field);
417   #if ( $number =~ s/^(\+|011)$countrycode// ) {
418   if ( $number =~ s/^\+$countrycode// ) {
419     $number = "0$number"
420       unless $number =~ /^1[389]/; #???
421   }
422   $number;
423 }
424
425 =item downstream_csv [ OPTION => VALUE, ... ]
426
427 =cut
428
429 my %export_names = (
430   'convergent'      => {},
431   'simple'  => {
432     'name'           => 'Simple',
433     'invoice_header' => "Date,Time,Name,Destination,Duration,Price",
434   },
435   'simple2' => {
436     'name'           => 'Simple with source',
437     'invoice_header' => "Date,Time,Called From,Destination,Duration,Price",
438                        #"Date,Time,Name,Called From,Destination,Duration,Price",
439   },
440   'default' => {
441     'name'           => 'Default',
442     'invoice_header' => 'Date,Time,Number,Destination,Duration,Price',
443   },
444   'source_default' => {
445     'name'           => 'Default with source',
446     'invoice_header' => 'Caller,Date,Time,Number,Destination,Duration,Price',
447   },
448 );
449
450 my %export_formats = (
451   'convergent' => [
452     'carriername', #CARRIER
453     sub { shift->_convergent_format('src') }, #SERVICE_NUMBER
454     sub { shift->_convergent_format('charged_party') }, #CHARGED_NUMBER
455     sub { time2str('%Y-%m-%d', shift->calldate_unix ) }, #DATE
456     sub { time2str('%T',       shift->calldate_unix ) }, #TIME
457     'billsec', #'duration', #DURATION
458     sub { shift->_convergent_format('dst') }, #NUMBER_DIALED
459     '', #XXX add (from prefixes in most recent email) #FROM_DESC
460     '', #XXX add (from prefixes in most recent email) #TO_DESC
461     'calltypename', #CLASS_CODE
462     'rated_price', #PRICE
463     sub { shift->rated_price ? 'Y' : 'N' }, #RATED
464     '', #OTHER_INFO
465   ],
466   'simple' => [
467     sub { time2str('%D', shift->calldate_unix ) },   #DATE
468     sub { time2str('%r', shift->calldate_unix ) },   #TIME
469     'userfield',                                     #USER
470     'dst',                                           #NUMBER_DIALED
471     sub { sprintf('%.2fm', shift->billsec / 60 ) },  #DURATION
472     #sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
473     sub { my($cdr, %opt) = @_; $opt{money_char}. $opt{charge}; }, #PRICE
474   ],
475   'simple2' => [
476     sub { time2str('%D', shift->calldate_unix ) },   #DATE
477     sub { time2str('%r', shift->calldate_unix ) },   #TIME
478     #'userfield',                                     #USER
479     'dst',                                           #NUMBER_DIALED
480     'src',                                           #called from
481     sub { sprintf('%.2fm', shift->billsec / 60 ) },  #DURATION
482     #sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
483     sub { my($cdr, %opt) = @_; $opt{money_char}. $opt{charge}; }, #PRICE
484   ],
485   'default' => [
486
487     #DATE
488     sub { time2str('%D', shift->calldate_unix ) },
489           # #time2str("%Y %b %d - %r", $cdr->calldate_unix ),
490
491     #TIME
492     sub { time2str('%r', shift->calldate_unix ) },
493           # time2str("%c", $cdr->calldate_unix),  #XXX this should probably be a config option dropdown so they can select US vs- rest of world dates or whatnot
494
495     #DEST ("Number")
496     sub { my($cdr, %opt) = @_; $opt{pretty_dst} || $cdr->dst; },
497
498     #REGIONNAME ("Destination")
499     sub { my($cdr, %opt) = @_; $opt{dst_regionname}; },
500
501     #DURATION
502     sub { my($cdr, %opt) = @_;
503           $opt{minutes}. ( $opt{granularity} ? 'm' : ' call' );
504         },
505
506     #PRICE
507     sub { my($cdr, %opt) = @_; $opt{money_char}. $opt{charge}; },
508
509   ],
510 );
511 $export_formats{'source_default'} = [ 'src', @{ $export_formats{'default'} }, ];
512
513 sub downstream_csv {
514   my( $self, %opt ) = @_;
515
516   my $format = $opt{'format'}; # 'convergent';
517   return "Unknown format $format" unless exists $export_formats{$format};
518
519   #my $conf = new FS::Conf;
520   #$opt{'money_char'} ||= $conf->config('money_char') || '$';
521   $opt{'money_char'} ||= FS::Conf->new->config('money_char') || '$';
522
523   eval "use Text::CSV_XS;";
524   die $@ if $@;
525   my $csv = new Text::CSV_XS;
526
527   my @columns =
528     map {
529           ref($_) ? &{$_}($self, %opt) : $self->$_();
530         }
531     @{ $export_formats{$format} };
532
533   my $status = $csv->combine(@columns);
534   die "FS::CDR: error combining ". $csv->error_input(). "into downstream CSV"
535     unless $status;
536
537   $csv->string;
538
539 }
540
541 =back
542
543 =head1 CLASS METHODS
544
545 =over 4
546
547 =item invoice_formats
548
549 Returns an ordered list of key value pairs containing invoice format names
550 as keys (for use with part_pkg::voip_cdr) and "pretty" format names as values.
551
552 =cut
553
554 sub invoice_formats {
555   map { ($_ => $export_names{$_}->{'name'}) }
556     grep { $export_names{$_}->{'invoice_header'} }
557     keys %export_names;
558 }
559
560 =item invoice_header FORMAT
561
562 Returns a scalar containing the CSV column header for invoice format FORMAT.
563
564 =cut
565
566 sub invoice_header {
567   my $format = shift;
568   $export_names{$format}->{'invoice_header'};
569 }
570
571 =item import_formats
572
573 Returns an ordered list of key value pairs containing import format names
574 as keys (for use with batch_import) and "pretty" format names as values.
575
576 =cut
577
578 #false laziness w/part_pkg & part_export
579
580 my %cdr_info;
581 foreach my $INC ( @INC ) {
582   warn "globbing $INC/FS/cdr/*.pm\n" if $DEBUG;
583   foreach my $file ( glob("$INC/FS/cdr/*.pm") ) {
584     warn "attempting to load CDR format info from $file\n" if $DEBUG;
585     $file =~ /\/(\w+)\.pm$/ or do {
586       warn "unrecognized file in $INC/FS/cdr/: $file\n";
587       next;
588     };
589     my $mod = $1;
590     my $info = eval "use FS::cdr::$mod; ".
591                     "\\%FS::cdr::$mod\::info;";
592     if ( $@ ) {
593       die "error using FS::cdr::$mod (skipping): $@\n" if $@;
594       next;
595     }
596     unless ( keys %$info ) {
597       warn "no %info hash found in FS::cdr::$mod, skipping\n";
598       next;
599     }
600     warn "got CDR format info from FS::cdr::$mod: $info\n" if $DEBUG;
601     if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
602       warn "skipping disabled CDR format FS::cdr::$mod" if $DEBUG;
603       next;
604     }
605     $cdr_info{$mod} = $info;
606   }
607 }
608
609 tie my %import_formats, 'Tie::IxHash',
610   map  { $_ => $cdr_info{$_}->{'name'} }
611   sort { $cdr_info{$a}->{'weight'} <=> $cdr_info{$b}->{'weight'} }
612   grep { exists($cdr_info{$_}->{'import_fields'}) }
613   keys %cdr_info;
614
615 sub import_formats {
616   %import_formats;
617 }
618
619 sub _cdr_min_parser_maker {
620   my $field = shift;
621   my @fields = ref($field) ? @$field : ($field);
622   @fields = qw( billsec duration ) unless scalar(@fields) && $fields[0];
623   return sub {
624     my( $cdr, $min ) = @_;
625     my $sec = eval { _cdr_min_parse($min) };
626     die "error parsing seconds for @fields from $min minutes: $@\n" if $@;
627     $cdr->$_($sec) foreach @fields;
628   };
629 }
630
631 sub _cdr_min_parse {
632   my $min = shift;
633   sprintf('%.0f', $min * 60 );
634 }
635
636 sub _cdr_date_parser_maker {
637   my $field = shift;
638   my @fields = ref($field) ? @$field : ($field);
639   return sub {
640     my( $cdr, $datestring ) = @_;
641     my $unixdate = eval { _cdr_date_parse($datestring) };
642     die "error parsing date for @fields from $datestring: $@\n" if $@;
643     $cdr->$_($unixdate) foreach @fields;
644   };
645 }
646
647 sub _cdr_date_parse {
648   my $date = shift;
649
650   return '' unless length($date); #that's okay, it becomes NULL
651
652   my($year, $mon, $day, $hour, $min, $sec);
653
654   #$date =~ /^\s*(\d{4})[\-\/]\(\d{1,2})[\-\/](\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})\s*$/
655   #taqua  #2007-10-31 08:57:24.113000000
656
657   if ( $date =~ /^\s*(\d{4})\D(\d{1,2})\D(\d{1,2})\s+(\d{1,2})\D(\d{1,2})\D(\d{1,2})(\D|$)/ ) {
658     ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
659   } elsif ( $date  =~ /^\s*(\d{1,2})\D(\d{1,2})\D(\d{4})\s+(\d{1,2})\D(\d{1,2})\D(\d{1,2})(\D|$)/ ) {
660     ($mon, $day, $year, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
661   } else {
662      die "unparsable date: $date"; #maybe we shouldn't die...
663   }
664
665   return '' if $year == 1900 && $mon == 1 && $day == 1
666             && $hour == 0    && $min == 0 && $sec == 0;
667
668   timelocal($sec, $min, $hour, $day, $mon-1, $year);
669 }
670
671 =item batch_import HASHREF
672
673 Imports CDR records.  Available options are:
674
675 =over 4
676
677 =item file
678
679 Filename
680
681 =item format
682
683 =item params
684
685 Hash reference of preset fields, typically cdrbatch
686
687 =item empty_ok
688
689 Set true to prevent throwing an error on empty imports
690
691 =back
692
693 =cut
694
695 my %import_options = (
696   'table'   => 'cdr',
697
698   'formats' => { map { $_ => $cdr_info{$_}->{'import_fields'}; }
699                      keys %cdr_info
700                },
701
702                           #drop the || 'csv' to allow auto xls for csv types?
703   'format_types' => { map { $_ => ( lc($cdr_info{$_}->{'type'}) || 'csv' ); }
704                           keys %cdr_info
705                     },
706
707   'format_headers' => { map { $_ => ( $cdr_info{$_}->{'header'} || 0 ); }
708                             keys %cdr_info
709                       },
710
711   'format_sep_chars' => { map { $_ => $cdr_info{$_}->{'sep_char'}; }
712                               keys %cdr_info
713                         },
714
715   'format_fixedlength_formats' =>
716     { map { $_ => $cdr_info{$_}->{'fixedlength_format'}; }
717           keys %cdr_info
718     },
719 );
720
721 sub _import_options {
722   \%import_options;
723 }
724
725 sub batch_import {
726   my $opt = shift;
727
728   my $iopt = _import_options;
729   $opt->{$_} = $iopt->{$_} foreach keys %$iopt;
730
731   FS::Record::batch_import( $opt );
732
733 }
734
735 =item process_batch_import
736
737 =cut
738
739 sub process_batch_import {
740   my $job = shift;
741
742   my $opt = _import_options;
743   $opt->{'params'} = [ 'format', 'cdrbatch' ];
744
745   FS::Record::process_batch_import( $job, $opt, @_ );
746
747 }
748 #  if ( $format eq 'simple' ) { #should be a callback or opt in FS::cdr::simple
749 #    @columns = map { s/^ +//; $_; } @columns;
750 #  }
751
752 =back
753
754 =head1 BUGS
755
756 =head1 SEE ALSO
757
758 L<FS::Record>, schema.html from the base documentation.
759
760 =cut
761
762 1;
763