show post-granularity duration if available for all export formats
[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   $self->set_charged_party;
250
251   #check the foreign keys even?
252   #do we want to outright *reject* the CDR?
253   my $error =
254        $self->ut_numbern('acctid')
255
256   #add a config option to turn these back on if someone needs 'em
257   #
258   #  #Usage = 1, S&E = 7, OC&C = 8
259   #  || $self->ut_foreign_keyn('cdrtypenum',  'cdr_type',     'cdrtypenum' )
260   #
261   #  #the big list in appendix 2
262   #  || $self->ut_foreign_keyn('calltypenum', 'cdr_calltype', 'calltypenum' )
263   #
264   #  # Telstra =1, Optus = 2, RSL COM = 3
265   #  || $self->ut_foreign_keyn('carrierid', 'cdr_carrier', 'carrierid' )
266   ;
267   return $error if $error;
268
269   $self->SUPER::check;
270 }
271
272 =item set_charged_party
273
274 If the charged_party field is already set, does nothing.  Otherwise:
275
276 If the cdr-charged_party-accountcode config option is enabled, sets the
277 charged_party to the accountcode.
278
279 Otherwise sets the charged_party normally: to the src field in most cases,
280 or to the dst field if it is a toll free number.
281
282 =cut
283
284 sub set_charged_party {
285   my $self = shift;
286
287   unless ( $self->charged_party ) {
288
289     my $conf = new FS::Conf;
290
291     if ( $conf->exists('cdr-charged_party-accountcode') && $self->accountcode ){
292
293       $self->charged_party( $self->accountcode );
294
295     } else {
296
297       if ( $self->dst =~ /^(\+?1)?8[02-8]{2}/ ) {
298         $self->charged_party($self->dst);
299       } else {
300         $self->charged_party($self->src);
301       }
302
303     }
304
305   }
306
307 }
308
309 =item set_status_and_rated_price STATUS [ RATED_PRICE ]
310
311 Sets the status to the provided string.  If there is an error, returns the
312 error, otherwise returns false.
313
314 =cut
315
316 sub set_status_and_rated_price {
317   my($self, $status, $rated_price) = @_;
318   $self->freesidestatus($status);
319   $self->rated_price($rated_price);
320   $self->replace();
321 }
322
323 =item calldate_unix 
324
325 Parses the calldate in SQL string format and returns a UNIX timestamp.
326
327 =cut
328
329 sub calldate_unix {
330   str2time(shift->calldate);
331 }
332
333 =item startdate_sql
334
335 Parses the startdate in UNIX timestamp format and returns a string in SQL
336 format.
337
338 =cut
339
340 sub startdate_sql {
341   my($sec,$min,$hour,$mday,$mon,$year) = localtime(shift->startdate);
342   $mon++;
343   $year += 1900;
344   "$year-$mon-$mday $hour:$min:$sec";
345 }
346
347 =item cdr_carrier
348
349 Returns the FS::cdr_carrier object associated with this CDR, or false if no
350 carrierid is defined.
351
352 =cut
353
354 my %carrier_cache = ();
355
356 sub cdr_carrier {
357   my $self = shift;
358   return '' unless $self->carrierid;
359   $carrier_cache{$self->carrierid} ||=
360     qsearchs('cdr_carrier', { 'carrierid' => $self->carrierid } );
361 }
362
363 =item carriername 
364
365 Returns the carrier name (see L<FS::cdr_carrier>), or the empty string if
366 no FS::cdr_carrier object is assocated with this CDR.
367
368 =cut
369
370 sub carriername {
371   my $self = shift;
372   my $cdr_carrier = $self->cdr_carrier;
373   $cdr_carrier ? $cdr_carrier->carriername : '';
374 }
375
376 =item cdr_calltype
377
378 Returns the FS::cdr_calltype object associated with this CDR, or false if no
379 calltypenum is defined.
380
381 =cut
382
383 my %calltype_cache = ();
384
385 sub cdr_calltype {
386   my $self = shift;
387   return '' unless $self->calltypenum;
388   $calltype_cache{$self->calltypenum} ||=
389     qsearchs('cdr_calltype', { 'calltypenum' => $self->calltypenum } );
390 }
391
392 =item calltypename 
393
394 Returns the call type name (see L<FS::cdr_calltype>), or the empty string if
395 no FS::cdr_calltype object is assocated with this CDR.
396
397 =cut
398
399 sub calltypename {
400   my $self = shift;
401   my $cdr_calltype = $self->cdr_calltype;
402   $cdr_calltype ? $cdr_calltype->calltypename : '';
403 }
404
405 =item cdr_upstream_rate
406
407 Returns the upstream rate mapping (see L<FS::cdr_upstream_rate>), or the empty
408 string if no FS::cdr_upstream_rate object is associated with this CDR.
409
410 =cut
411
412 sub cdr_upstream_rate {
413   my $self = shift;
414   return '' unless $self->upstream_rateid;
415   qsearchs('cdr_upstream_rate', { 'upstream_rateid' => $self->upstream_rateid })
416     or '';
417 }
418
419 =item _convergent_format COLUMN [ COUNTRYCODE ]
420
421 Returns the number in COLUMN formatted as follows:
422
423 If the country code does not match COUNTRYCODE (default "61"), it is returned
424 unchanged.
425
426 If the country code does match COUNTRYCODE (default "61"), it is removed.  In
427 addiiton, "0" is prepended unless the number starts with 13, 18 or 19. (???)
428
429 =cut
430
431 sub _convergent_format {
432   my( $self, $field ) = ( shift, shift );
433   my $countrycode = scalar(@_) ? shift : '61'; #+61 = australia
434   #my $number = $self->$field();
435   my $number = $self->get($field);
436   #if ( $number =~ s/^(\+|011)$countrycode// ) {
437   if ( $number =~ s/^\+$countrycode// ) {
438     $number = "0$number"
439       unless $number =~ /^1[389]/; #???
440   }
441   $number;
442 }
443
444 =item downstream_csv [ OPTION => VALUE, ... ]
445
446 =cut
447
448 my %export_names = (
449   'convergent'      => {},
450   'simple'  => {
451     'name'           => 'Simple',
452     'invoice_header' => "Date,Time,Name,Destination,Duration,Price",
453   },
454   'simple2' => {
455     'name'           => 'Simple with source',
456     'invoice_header' => "Date,Time,Called From,Destination,Duration,Price",
457                        #"Date,Time,Name,Called From,Destination,Duration,Price",
458   },
459   'default' => {
460     'name'           => 'Default',
461     'invoice_header' => 'Date,Time,Number,Destination,Duration,Price',
462   },
463   'source_default' => {
464     'name'           => 'Default with source',
465     'invoice_header' => 'Caller,Date,Time,Number,Destination,Duration,Price',
466   },
467   'accountcode_default' => {
468     'name'           => 'Default plus accountcode',
469     'invoice_header' => 'Date,Time,Account,Number,Destination,Duration,Price',
470   },
471 );
472
473 my $duration_sub = sub {
474   my($cdr, %opt) = @_;
475   if ( $opt{minutes} ) {
476     $opt{minutes}. ( $opt{granularity} ? 'm' : ' call' );
477   } else {
478     sprintf('%.2fm', $cdr->billsec / 60 );
479   }
480 };
481
482 my %export_formats = (
483   'convergent' => [
484     'carriername', #CARRIER
485     sub { shift->_convergent_format('src') }, #SERVICE_NUMBER
486     sub { shift->_convergent_format('charged_party') }, #CHARGED_NUMBER
487     sub { time2str('%Y-%m-%d', shift->calldate_unix ) }, #DATE
488     sub { time2str('%T',       shift->calldate_unix ) }, #TIME
489     'billsec', #'duration', #DURATION
490     sub { shift->_convergent_format('dst') }, #NUMBER_DIALED
491     '', #XXX add (from prefixes in most recent email) #FROM_DESC
492     '', #XXX add (from prefixes in most recent email) #TO_DESC
493     'calltypename', #CLASS_CODE
494     'rated_price', #PRICE
495     sub { shift->rated_price ? 'Y' : 'N' }, #RATED
496     '', #OTHER_INFO
497   ],
498   'simple' => [
499     sub { time2str('%D', shift->calldate_unix ) },   #DATE
500     sub { time2str('%r', shift->calldate_unix ) },   #TIME
501     'userfield',                                     #USER
502     'dst',                                           #NUMBER_DIALED
503     $duration_sub,                                   #DURATION
504     #sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
505     sub { my($cdr, %opt) = @_; $opt{money_char}. $opt{charge}; }, #PRICE
506   ],
507   'simple2' => [
508     sub { time2str('%D', shift->calldate_unix ) },   #DATE
509     sub { time2str('%r', shift->calldate_unix ) },   #TIME
510     #'userfield',                                     #USER
511     'dst',                                           #NUMBER_DIALED
512     'src',                                           #called from
513     $duration_sub,                                   #DURATION
514     #sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
515     sub { my($cdr, %opt) = @_; $opt{money_char}. $opt{charge}; }, #PRICE
516   ],
517   'default' => [
518
519     #DATE
520     sub { time2str('%D', shift->calldate_unix ) },
521           # #time2str("%Y %b %d - %r", $cdr->calldate_unix ),
522
523     #TIME
524     sub { time2str('%r', shift->calldate_unix ) },
525           # 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
526
527     #DEST ("Number")
528     sub { my($cdr, %opt) = @_; $opt{pretty_dst} || $cdr->dst; },
529
530     #REGIONNAME ("Destination")
531     sub { my($cdr, %opt) = @_; $opt{dst_regionname}; },
532
533     #DURATION
534     $duration_sub,
535
536     #PRICE
537     sub { my($cdr, %opt) = @_; $opt{money_char}. $opt{charge}; },
538
539   ],
540 );
541 $export_formats{'source_default'} = [ 'src', @{ $export_formats{'default'} }, ];
542 $export_formats{'accountcode_default'} =
543   [ @{ $export_formats{'default'} }[0,1],
544     'accountcode',
545     @{ $export_formats{'default'} }[2..5],
546   ];
547
548 sub downstream_csv {
549   my( $self, %opt ) = @_;
550
551   my $format = $opt{'format'}; # 'convergent';
552   return "Unknown format $format" unless exists $export_formats{$format};
553
554   #my $conf = new FS::Conf;
555   #$opt{'money_char'} ||= $conf->config('money_char') || '$';
556   $opt{'money_char'} ||= FS::Conf->new->config('money_char') || '$';
557
558   eval "use Text::CSV_XS;";
559   die $@ if $@;
560   my $csv = new Text::CSV_XS;
561
562   my @columns =
563     map {
564           ref($_) ? &{$_}($self, %opt) : $self->$_();
565         }
566     @{ $export_formats{$format} };
567
568   my $status = $csv->combine(@columns);
569   die "FS::CDR: error combining ". $csv->error_input(). "into downstream CSV"
570     unless $status;
571
572   $csv->string;
573
574 }
575
576 =back
577
578 =head1 CLASS METHODS
579
580 =over 4
581
582 =item invoice_formats
583
584 Returns an ordered list of key value pairs containing invoice format names
585 as keys (for use with part_pkg::voip_cdr) and "pretty" format names as values.
586
587 =cut
588
589 sub invoice_formats {
590   map { ($_ => $export_names{$_}->{'name'}) }
591     grep { $export_names{$_}->{'invoice_header'} }
592     keys %export_names;
593 }
594
595 =item invoice_header FORMAT
596
597 Returns a scalar containing the CSV column header for invoice format FORMAT.
598
599 =cut
600
601 sub invoice_header {
602   my $format = shift;
603   $export_names{$format}->{'invoice_header'};
604 }
605
606 =item import_formats
607
608 Returns an ordered list of key value pairs containing import format names
609 as keys (for use with batch_import) and "pretty" format names as values.
610
611 =cut
612
613 #false laziness w/part_pkg & part_export
614
615 my %cdr_info;
616 foreach my $INC ( @INC ) {
617   warn "globbing $INC/FS/cdr/*.pm\n" if $DEBUG;
618   foreach my $file ( glob("$INC/FS/cdr/*.pm") ) {
619     warn "attempting to load CDR format info from $file\n" if $DEBUG;
620     $file =~ /\/(\w+)\.pm$/ or do {
621       warn "unrecognized file in $INC/FS/cdr/: $file\n";
622       next;
623     };
624     my $mod = $1;
625     my $info = eval "use FS::cdr::$mod; ".
626                     "\\%FS::cdr::$mod\::info;";
627     if ( $@ ) {
628       die "error using FS::cdr::$mod (skipping): $@\n" if $@;
629       next;
630     }
631     unless ( keys %$info ) {
632       warn "no %info hash found in FS::cdr::$mod, skipping\n";
633       next;
634     }
635     warn "got CDR format info from FS::cdr::$mod: $info\n" if $DEBUG;
636     if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
637       warn "skipping disabled CDR format FS::cdr::$mod" if $DEBUG;
638       next;
639     }
640     $cdr_info{$mod} = $info;
641   }
642 }
643
644 tie my %import_formats, 'Tie::IxHash',
645   map  { $_ => $cdr_info{$_}->{'name'} }
646   sort { $cdr_info{$a}->{'weight'} <=> $cdr_info{$b}->{'weight'} }
647   grep { exists($cdr_info{$_}->{'import_fields'}) }
648   keys %cdr_info;
649
650 sub import_formats {
651   %import_formats;
652 }
653
654 sub _cdr_min_parser_maker {
655   my $field = shift;
656   my @fields = ref($field) ? @$field : ($field);
657   @fields = qw( billsec duration ) unless scalar(@fields) && $fields[0];
658   return sub {
659     my( $cdr, $min ) = @_;
660     my $sec = eval { _cdr_min_parse($min) };
661     die "error parsing seconds for @fields from $min minutes: $@\n" if $@;
662     $cdr->$_($sec) foreach @fields;
663   };
664 }
665
666 sub _cdr_min_parse {
667   my $min = shift;
668   sprintf('%.0f', $min * 60 );
669 }
670
671 sub _cdr_date_parser_maker {
672   my $field = shift;
673   my @fields = ref($field) ? @$field : ($field);
674   return sub {
675     my( $cdr, $datestring ) = @_;
676     my $unixdate = eval { _cdr_date_parse($datestring) };
677     die "error parsing date for @fields from $datestring: $@\n" if $@;
678     $cdr->$_($unixdate) foreach @fields;
679   };
680 }
681
682 sub _cdr_date_parse {
683   my $date = shift;
684
685   return '' unless length($date); #that's okay, it becomes NULL
686
687   my($year, $mon, $day, $hour, $min, $sec);
688
689   #$date =~ /^\s*(\d{4})[\-\/]\(\d{1,2})[\-\/](\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})\s*$/
690   #taqua  #2007-10-31 08:57:24.113000000
691
692   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|$)/ ) {
693     ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
694   } 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|$)/ ) {
695     ($mon, $day, $year, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
696   } else {
697      die "unparsable date: $date"; #maybe we shouldn't die...
698   }
699
700   return '' if $year == 1900 && $mon == 1 && $day == 1
701             && $hour == 0    && $min == 0 && $sec == 0;
702
703   timelocal($sec, $min, $hour, $day, $mon-1, $year);
704 }
705
706 =item batch_import HASHREF
707
708 Imports CDR records.  Available options are:
709
710 =over 4
711
712 =item file
713
714 Filename
715
716 =item format
717
718 =item params
719
720 Hash reference of preset fields, typically cdrbatch
721
722 =item empty_ok
723
724 Set true to prevent throwing an error on empty imports
725
726 =back
727
728 =cut
729
730 my %import_options = (
731   'table'   => 'cdr',
732
733   'formats' => { map { $_ => $cdr_info{$_}->{'import_fields'}; }
734                      keys %cdr_info
735                },
736
737                           #drop the || 'csv' to allow auto xls for csv types?
738   'format_types' => { map { $_ => ( lc($cdr_info{$_}->{'type'}) || 'csv' ); }
739                           keys %cdr_info
740                     },
741
742   'format_headers' => { map { $_ => ( $cdr_info{$_}->{'header'} || 0 ); }
743                             keys %cdr_info
744                       },
745
746   'format_sep_chars' => { map { $_ => $cdr_info{$_}->{'sep_char'}; }
747                               keys %cdr_info
748                         },
749
750   'format_fixedlength_formats' =>
751     { map { $_ => $cdr_info{$_}->{'fixedlength_format'}; }
752           keys %cdr_info
753     },
754 );
755
756 sub _import_options {
757   \%import_options;
758 }
759
760 sub batch_import {
761   my $opt = shift;
762
763   my $iopt = _import_options;
764   $opt->{$_} = $iopt->{$_} foreach keys %$iopt;
765
766   FS::Record::batch_import( $opt );
767
768 }
769
770 =item process_batch_import
771
772 =cut
773
774 sub process_batch_import {
775   my $job = shift;
776
777   my $opt = _import_options;
778   $opt->{'params'} = [ 'format', 'cdrbatch' ];
779
780   FS::Record::process_batch_import( $job, $opt, @_ );
781
782 }
783 #  if ( $format eq 'simple' ) { #should be a callback or opt in FS::cdr::simple
784 #    @columns = map { s/^ +//; $_; } @columns;
785 #  }
786
787 =back
788
789 =head1 BUGS
790
791 =head1 SEE ALSO
792
793 L<FS::Record>, schema.html from the base documentation.
794
795 =cut
796
797 1;
798