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