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