voxline cdrs
[freeside.git] / FS / FS / cdr.pm
1 package FS::cdr;
2
3 use strict;
4 use vars qw( @ISA );
5 use Date::Parse;
6 use Date::Format;
7 use Time::Local;
8 use FS::UID qw( dbh );
9 use FS::Record qw( qsearch qsearchs );
10 use FS::cdr_type;
11 use FS::cdr_calltype;
12 use FS::cdr_carrier;
13 use FS::cdr_upstream_rate;
14
15 @ISA = qw(FS::Record);
16
17 =head1 NAME
18
19 FS::cdr - Object methods for cdr records
20
21 =head1 SYNOPSIS
22
23   use FS::cdr;
24
25   $record = new FS::cdr \%hash;
26   $record = new FS::cdr { 'column' => 'value' };
27
28   $error = $record->insert;
29
30   $error = $new_record->replace($old_record);
31
32   $error = $record->delete;
33
34   $error = $record->check;
35
36 =head1 DESCRIPTION
37
38 An FS::cdr object represents an Call Data Record, typically from a telephony
39 system or provider of some sort.  FS::cdr inherits from FS::Record.  The
40 following fields are currently supported:
41
42 =over 4
43
44 =item acctid - primary key
45
46 =item calldate - Call timestamp (SQL timestamp)
47
48 =item clid - Caller*ID with text
49
50 =item src - Caller*ID number / Source number
51
52 =item dst - Destination extension
53
54 =item dcontext - Destination context
55
56 =item channel - Channel used
57
58 =item dstchannel - Destination channel if appropriate
59
60 =item lastapp - Last application if appropriate
61
62 =item lastdata - Last application data
63
64 =item startdate - Start of call (UNIX-style integer timestamp)
65
66 =item answerdate - Answer time of call (UNIX-style integer timestamp)
67
68 =item enddate - End time of call (UNIX-style integer timestamp)
69
70 =item duration - Total time in system, in seconds
71
72 =item billsec - Total time call is up, in seconds
73
74 =item disposition - What happened to the call: ANSWERED, NO ANSWER, BUSY 
75
76 =item amaflags - What flags to use: BILL, IGNORE etc, specified on a per channel basis like accountcode. 
77
78 =cut
79
80   #ignore the "omit" and "documentation" AMAs??
81   #AMA = Automated Message Accounting. 
82   #default: Sets the system default. 
83   #omit: Do not record calls. 
84   #billing: Mark the entry for billing 
85   #documentation: Mark the entry for documentation.
86
87 =item accountcode - CDR account number to use: account
88
89 =item uniqueid - Unique channel identifier (Unitel/RSLCOM Event ID)
90
91 =item userfield - CDR user-defined field
92
93 =item cdr_type - CDR type - see L<FS::cdr_type> (Usage = 1, S&E = 7, OC&C = 8)
94
95 =item charged_party - Service number to be billed
96
97 =item upstream_currency - Wholesale currency from upstream
98
99 =item upstream_price - Wholesale price from upstream
100
101 =item upstream_rateplanid - Upstream rate plan ID
102
103 =item rated_price - Rated (or re-rated) price
104
105 =item distance - km (need units field?)
106
107 =item islocal - Local - 1, Non Local = 0
108
109 =item calltypenum - Type of call - see L<FS::cdr_calltype>
110
111 =item description - Description (cdr_type 7&8 only) (used for cust_bill_pkg.itemdesc)
112
113 =item quantity - Number of items (cdr_type 7&8 only)
114
115 =item carrierid - Upstream Carrier ID (see L<FS::cdr_carrier>) 
116
117 =cut
118
119 #Telstra =1, Optus = 2, RSL COM = 3
120
121 =item upstream_rateid - Upstream Rate ID
122
123 =item svcnum - Link to customer service (see L<FS::cust_svc>)
124
125 =item freesidestatus - NULL, done (or something)
126
127 =back
128
129 =head1 METHODS
130
131 =over 4
132
133 =item new HASHREF
134
135 Creates a new CDR.  To add the CDR to the database, see L<"insert">.
136
137 Note that this stores the hash reference, not a distinct copy of the hash it
138 points to.  You can ask the object for a copy with the I<hash> method.
139
140 =cut
141
142 # the new method can be inherited from FS::Record, if a table method is defined
143
144 sub table { 'cdr'; }
145
146 =item insert
147
148 Adds this record to the database.  If there is an error, returns the error,
149 otherwise returns false.
150
151 =cut
152
153 # the insert method can be inherited from FS::Record
154
155 =item delete
156
157 Delete this record from the database.
158
159 =cut
160
161 # the delete method can be inherited from FS::Record
162
163 =item replace OLD_RECORD
164
165 Replaces the OLD_RECORD with this one in the database.  If there is an error,
166 returns the error, otherwise returns false.
167
168 =cut
169
170 # the replace method can be inherited from FS::Record
171
172 =item check
173
174 Checks all fields to make sure this is a valid CDR.  If there is
175 an error, returns the error, otherwise returns false.  Called by the insert
176 and replace methods.
177
178 Note: Unlike most types of records, we don't want to "reject" a CDR and we want
179 to process them as quickly as possible, so we allow the database to check most
180 of the data.
181
182 =cut
183
184 sub check {
185   my $self = shift;
186
187 # we don't want to "reject" a CDR like other sorts of input...
188 #  my $error = 
189 #    $self->ut_numbern('acctid')
190 ##    || $self->ut_('calldate')
191 #    || $self->ut_text('clid')
192 #    || $self->ut_text('src')
193 #    || $self->ut_text('dst')
194 #    || $self->ut_text('dcontext')
195 #    || $self->ut_text('channel')
196 #    || $self->ut_text('dstchannel')
197 #    || $self->ut_text('lastapp')
198 #    || $self->ut_text('lastdata')
199 #    || $self->ut_numbern('startdate')
200 #    || $self->ut_numbern('answerdate')
201 #    || $self->ut_numbern('enddate')
202 #    || $self->ut_number('duration')
203 #    || $self->ut_number('billsec')
204 #    || $self->ut_text('disposition')
205 #    || $self->ut_number('amaflags')
206 #    || $self->ut_text('accountcode')
207 #    || $self->ut_text('uniqueid')
208 #    || $self->ut_text('userfield')
209 #    || $self->ut_numbern('cdrtypenum')
210 #    || $self->ut_textn('charged_party')
211 ##    || $self->ut_n('upstream_currency')
212 ##    || $self->ut_n('upstream_price')
213 #    || $self->ut_numbern('upstream_rateplanid')
214 ##    || $self->ut_n('distance')
215 #    || $self->ut_numbern('islocal')
216 #    || $self->ut_numbern('calltypenum')
217 #    || $self->ut_textn('description')
218 #    || $self->ut_numbern('quantity')
219 #    || $self->ut_numbern('carrierid')
220 #    || $self->ut_numbern('upstream_rateid')
221 #    || $self->ut_numbern('svcnum')
222 #    || $self->ut_textn('freesidestatus')
223 #  ;
224 #  return $error if $error;
225
226   $self->calldate( $self->startdate_sql )
227     if !$self->calldate && $self->startdate;
228
229   unless ( $self->charged_party ) {
230     if ( $self->dst =~ /^(\+?1)?8[02-8]{2}/ ) {
231       $self->charged_party($self->dst);
232     } else {
233       $self->charged_party($self->src);
234     }
235   }
236
237   #check the foreign keys even?
238   #do we want to outright *reject* the CDR?
239   my $error =
240        $self->ut_numbern('acctid')
241
242     #Usage = 1, S&E = 7, OC&C = 8
243     || $self->ut_foreign_keyn('cdrtypenum',  'cdr_type',     'cdrtypenum' )
244
245     #the big list in appendix 2
246     || $self->ut_foreign_keyn('calltypenum', 'cdr_calltype', 'calltypenum' )
247
248     # Telstra =1, Optus = 2, RSL COM = 3
249     || $self->ut_foreign_keyn('carrierid', 'cdr_carrier', 'carrierid' )
250   ;
251   return $error if $error;
252
253   $self->SUPER::check;
254 }
255
256 =item set_status_and_rated_price STATUS [ RATED_PRICE ]
257
258 Sets the status to the provided string.  If there is an error, returns the
259 error, otherwise returns false.
260
261 =cut
262
263 sub set_status_and_rated_price {
264   my($self, $status, $rated_price) = @_;
265   $self->freesidestatus($status);
266   $self->rated_price($rated_price);
267   $self->replace();
268 }
269
270 =item calldate_unix 
271
272 Parses the calldate in SQL string format and returns a UNIX timestamp.
273
274 =cut
275
276 sub calldate_unix {
277   str2time(shift->calldate);
278 }
279
280 =item startdate_sql
281
282 Parses the startdate in UNIX timestamp format and returns a string in SQL
283 format.
284
285 =cut
286
287 sub startdate_sql {
288   my($sec,$min,$hour,$mday,$mon,$year) = localtime(shift->startdate);
289   $mon++;
290   $year += 1900;
291   "$year-$mon-$mday $hour:$min:$sec";
292 }
293
294 =item cdr_carrier
295
296 Returns the FS::cdr_carrier object associated with this CDR, or false if no
297 carrierid is defined.
298
299 =cut
300
301 my %carrier_cache = ();
302
303 sub cdr_carrier {
304   my $self = shift;
305   return '' unless $self->carrierid;
306   $carrier_cache{$self->carrierid} ||=
307     qsearchs('cdr_carrier', { 'carrierid' => $self->carrierid } );
308 }
309
310 =item carriername 
311
312 Returns the carrier name (see L<FS::cdr_carrier>), or the empty string if
313 no FS::cdr_carrier object is assocated with this CDR.
314
315 =cut
316
317 sub carriername {
318   my $self = shift;
319   my $cdr_carrier = $self->cdr_carrier;
320   $cdr_carrier ? $cdr_carrier->carriername : '';
321 }
322
323 =item cdr_calltype
324
325 Returns the FS::cdr_calltype object associated with this CDR, or false if no
326 calltypenum is defined.
327
328 =cut
329
330 my %calltype_cache = ();
331
332 sub cdr_calltype {
333   my $self = shift;
334   return '' unless $self->calltypenum;
335   $calltype_cache{$self->calltypenum} ||=
336     qsearchs('cdr_calltype', { 'calltypenum' => $self->calltypenum } );
337 }
338
339 =item calltypename 
340
341 Returns the call type name (see L<FS::cdr_calltype>), or the empty string if
342 no FS::cdr_calltype object is assocated with this CDR.
343
344 =cut
345
346 sub calltypename {
347   my $self = shift;
348   my $cdr_calltype = $self->cdr_calltype;
349   $cdr_calltype ? $cdr_calltype->calltypename : '';
350 }
351
352 =item cdr_upstream_rate
353
354 Returns the upstream rate mapping (see L<FS::cdr_upstream_rate>), or the empty
355 string if no FS::cdr_upstream_rate object is associated with this CDR.
356
357 =cut
358
359 sub cdr_upstream_rate {
360   my $self = shift;
361   return '' unless $self->upstream_rateid;
362   qsearchs('cdr_upstream_rate', { 'upstream_rateid' => $self->upstream_rateid })
363     or '';
364 }
365
366 =item _convergent_format COLUMN [ COUNTRYCODE ]
367
368 Returns the number in COLUMN formatted as follows:
369
370 If the country code does not match COUNTRYCODE (default "61"), it is returned
371 unchanged.
372
373 If the country code does match COUNTRYCODE (default "61"), it is removed.  In
374 addiiton, "0" is prepended unless the number starts with 13, 18 or 19. (???)
375
376 =cut
377
378 sub _convergent_format {
379   my( $self, $field ) = ( shift, shift );
380   my $countrycode = scalar(@_) ? shift : '61'; #+61 = australia
381   #my $number = $self->$field();
382   my $number = $self->get($field);
383   #if ( $number =~ s/^(\+|011)$countrycode// ) {
384   if ( $number =~ s/^\+$countrycode// ) {
385     $number = "0$number"
386       unless $number =~ /^1[389]/; #???
387   }
388   $number;
389 }
390
391 =item downstream_csv [ OPTION => VALUE, ... ]
392
393 =cut
394
395 my %export_formats = (
396   'convergent' => [
397     'carriername', #CARRIER
398     sub { shift->_convergent_format('src') }, #SERVICE_NUMBER
399     sub { shift->_convergent_format('charged_party') }, #CHARGED_NUMBER
400     sub { time2str('%Y-%m-%d', shift->calldate_unix ) }, #DATE
401     sub { time2str('%T',       shift->calldate_unix ) }, #TIME
402     'billsec', #'duration', #DURATION
403     sub { shift->_convergent_format('dst') }, #NUMBER_DIALED
404     '', #XXX add (from prefixes in most recent email) #FROM_DESC
405     '', #XXX add (from prefixes in most recent email) #TO_DESC
406     'calltypename', #CLASS_CODE
407     'rated_price', #PRICE
408     sub { shift->rated_price ? 'Y' : 'N' }, #RATED
409     '', #OTHER_INFO
410   ],
411 );
412
413 sub downstream_csv {
414   my( $self, %opt ) = @_;
415
416   my $format = $opt{'format'}; # 'convergent';
417   return "Unknown format $format" unless exists $export_formats{$format};
418
419   eval "use Text::CSV_XS;";
420   die $@ if $@;
421   my $csv = new Text::CSV_XS;
422
423   my @columns =
424     map {
425           ref($_) ? &{$_}($self) : $self->$_();
426         }
427     @{ $export_formats{$format} };
428
429   my $status = $csv->combine(@columns);
430   die "FS::CDR: error combining ". $csv->error_input(). "into downstream CSV"
431     unless $status;
432
433   $csv->string;
434
435 }
436
437 =back
438
439 =head1 CLASS METHODS
440
441 =over 4
442
443 =item import_formats
444
445 Returns an ordered list of key value pairs containing import format names
446 as keys (for use with batch_import) and "pretty" format names as values.
447
448 =cut
449
450 sub import_formats {
451   (
452     'asterisk'       => 'Asterisk',
453     'taqua'          => 'Taqua',
454     'unitel'         => 'Unitel/RSLCOM',
455     'voxlinesystems' => 'VoxLineSystems',  #XXX? get the actual vendor name
456     'simple'         => 'Simple',
457   );
458 }
459
460 my($tmp_mday, $tmp_mon, $tmp_year);
461
462 sub _cdr_date_parser_maker {
463   my $field = shift;
464   return sub {
465     my( $cdr, $date ) = @_;
466     #$cdr->$field( _cdr_date_parse($date) );
467     eval { $cdr->$field( _cdr_date_parse($date) ); };
468     die "error parsing date for $field from $date: $@\n" if $@;
469   };
470 }
471
472 sub _cdr_date_parse {
473   my $date = shift;
474
475   return '' unless length($date); #that's okay, it becomes NULL
476
477   #$date =~ /^\s*(\d{4})[\-\/]\(\d{1,2})[\-\/](\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})\s*$/
478   $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|$)/
479     or die "unparsable date: $date"; #maybe we shouldn't die...
480   my($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
481
482   return '' if $year == 1900 && $mon == 1 && $day == 1
483             && $hour == 0    && $min == 0 && $sec == 0;
484
485   timelocal($sec, $min, $hour, $day, $mon-1, $year);
486 }
487
488 #taqua  #2007-10-31 08:57:24.113000000
489
490 #http://www.the-asterisk-book.com/unstable/funktionen-cdr.html
491 my %amaflags = (
492   DEFAULT       => 0,
493   OMIT          => 1, #asterisk 1.4+
494   IGNORE        => 1, #asterisk 1.2
495   BILLING       => 2, #asterisk 1.4+
496   BILL          => 2, #asterisk 1.2
497   DOCUMENTATION => 3,
498   #? '' => 0,
499 );
500
501 my %import_formats = (
502   'asterisk' => [
503     'accountcode',
504     'src',
505     'dst',
506     'dcontext',
507     'clid',
508     'channel',
509     'dstchannel',
510     'lastapp',
511     'lastdata',
512     _cdr_date_parser_maker('startdate'),
513     _cdr_date_parser_maker('answerdate'),
514     _cdr_date_parser_maker('enddate'),
515     'duration',
516     'billsec',
517     'disposition',
518     sub { my($cdr, $amaflags) = @_; $cdr->amaflags($amaflags{$amaflags}); },
519     'uniqueid',
520     'userfield',
521   ],
522   'taqua' => [ #some of these are kind arbitrary...
523
524     sub { my($cdr, $field) = @_; },       #XXX interesting RecordType
525              # easy to fix: Can't find cdr.cdrtypenum 1 in cdr_type.cdrtypenum
526
527     sub { my($cdr, $field) = @_; },             #all10#RecordVersion
528     sub { my($cdr, $field) = @_; },       #OrigShelfNumber
529     sub { my($cdr, $field) = @_; },       #OrigCardNumber
530     sub { my($cdr, $field) = @_; },       #OrigCircuit
531     sub { my($cdr, $field) = @_; },       #OrigCircuitType
532     'uniqueid',                           #SequenceNumber
533     'accountcode',                        #SessionNumber
534     'src',                                #CallingPartyNumber
535     'dst',                                #CalledPartyNumber
536     _cdr_date_parser_maker('startdate'),  #CallArrivalTime
537     _cdr_date_parser_maker('enddate'),    #CallCompletionTime
538
539     #Disposition
540     #sub { my($cdr, $d ) = @_; $cdr->disposition( $disposition{$d}): },
541     'disposition',
542                                           #  -1 => '',
543                                           #   0 => '',
544                                           # 100 => '',
545                                           # 101 => '',
546                                           # 102 => '',
547                                           # 103 => '',
548                                           # 104 => '',
549                                           # 105 => '',
550                                           # 201 => '',
551                                           # 203 => '',
552
553     _cdr_date_parser_maker('answerdate'), #DispositionTime
554     sub { my($cdr, $field) = @_; },       #TCAP
555     sub { my($cdr, $field) = @_; },       #OutboundCarrierConnectTime
556     sub { my($cdr, $field) = @_; },       #OutboundCarrierDisconnectTime
557
558     #TermTrunkGroup
559     #it appears channels are actually part of trunk groups, but this data
560     #is interesting and we need a source and destination place to put it
561     'dstchannel',                         #TermTrunkGroup
562
563
564     sub { my($cdr, $field) = @_; },       #TermShelfNumber
565     sub { my($cdr, $field) = @_; },       #TermCardNumber
566     sub { my($cdr, $field) = @_; },       #TermCircuit
567     sub { my($cdr, $field) = @_; },       #TermCircuitType
568     sub { my($cdr, $field) = @_; },       #OutboundCarrierId
569     'charged_party',                      #BillingNumber
570     sub { my($cdr, $field) = @_; },       #SubscriberNumber
571     'lastapp',                            #ServiceName
572     sub { my($cdr, $field) = @_; },       #some weirdness #ChargeTime
573     'lastdata',                           #ServiceInformation
574     sub { my($cdr, $field) = @_; },       #FacilityInfo
575     sub { my($cdr, $field) = @_; },             #all 1900-01-01 0#CallTraceTime
576     sub { my($cdr, $field) = @_; },             #all-1#UniqueIndicator
577     sub { my($cdr, $field) = @_; },             #all-1#PresentationIndicator
578     sub { my($cdr, $field) = @_; },             #empty#Pin
579     sub { my($cdr, $field) = @_; },       #CallType
580     sub { my($cdr, $field) = @_; },           #Balt/empty #OrigRateCenter
581     sub { my($cdr, $field) = @_; },           #Balt/empty #TermRateCenter
582
583     #OrigTrunkGroup
584     #it appears channels are actually part of trunk groups, but this data
585     #is interesting and we need a source and destination place to put it
586     'channel',                            #OrigTrunkGroup
587
588     'userfield',                                #empty#UserDefined
589     sub { my($cdr, $field) = @_; },             #empty#PseudoDestinationNumber
590     sub { my($cdr, $field) = @_; },             #all-1#PseudoCarrierCode
591     sub { my($cdr, $field) = @_; },             #empty#PseudoANI
592     sub { my($cdr, $field) = @_; },             #all-1#PseudoFacilityInfo
593     sub { my($cdr, $field) = @_; },       #OrigDialedDigits
594     sub { my($cdr, $field) = @_; },             #all-1#OrigOutboundCarrier
595     sub { my($cdr, $field) = @_; },       #IncomingCarrierID
596     'dcontext',                           #JurisdictionInfo
597     sub { my($cdr, $field) = @_; },       #OrigDestDigits
598     sub { my($cdr, $field) = @_; },       #huh?#InsertTime
599     sub { my($cdr, $field) = @_; },       #key
600     sub { my($cdr, $field) = @_; },             #empty#AMALineNumber
601     sub { my($cdr, $field) = @_; },             #empty#AMAslpID
602     sub { my($cdr, $field) = @_; },             #empty#AMADigitsDialedWC
603     sub { my($cdr, $field) = @_; },       #OpxOffHook
604     sub { my($cdr, $field) = @_; },       #OpxOnHook
605
606         #acctid - primary key
607   #AUTO #calldate - Call timestamp (SQL timestamp)
608 #clid - Caller*ID with text
609         #XXX src - Caller*ID number / Source number
610         #XXX dst - Destination extension
611         #dcontext - Destination context
612         #channel - Channel used
613         #dstchannel - Destination channel if appropriate
614         #lastapp - Last application if appropriate
615         #lastdata - Last application data
616         #startdate - Start of call (UNIX-style integer timestamp)
617         #answerdate - Answer time of call (UNIX-style integer timestamp)
618         #enddate - End time of call (UNIX-style integer timestamp)
619   #HACK#duration - Total time in system, in seconds
620   #HACK#XXX billsec - Total time call is up, in seconds
621         #disposition - What happened to the call: ANSWERED, NO ANSWER, BUSY
622 #INT amaflags - What flags to use: BILL, IGNORE etc, specified on a per channel basis like accountcode.
623         #accountcode - CDR account number to use: account
624
625         #uniqueid - Unique channel identifier (Unitel/RSLCOM Event ID)
626         #userfield - CDR user-defined field
627
628         #X cdrtypenum - CDR type - see FS::cdr_type (Usage = 1, S&E = 7, OC&C = 8)
629         #XXX charged_party - Service number to be billed
630 #upstream_currency - Wholesale currency from upstream
631 #X upstream_price - Wholesale price from upstream
632 #upstream_rateplanid - Upstream rate plan ID
633 #rated_price - Rated (or re-rated) price
634 #distance - km (need units field?)
635 #islocal - Local - 1, Non Local = 0
636 #calltypenum - Type of call - see FS::cdr_calltype
637 #X description - Description (cdr_type 7&8 only) (used for cust_bill_pkg.itemdesc)
638 #quantity - Number of items (cdr_type 7&8 only)
639 #carrierid - Upstream Carrier ID (see FS::cdr_carrier)
640 #upstream_rateid - Upstream Rate ID
641
642         #svcnum - Link to customer service (see FS::cust_svc)
643         #freesidestatus - NULL, done (or something)
644
645   ],
646   'unitel' => [
647     'uniqueid',
648     #'cdr_type',
649     'cdrtypenum',
650     'calldate', # may need massaging?  huh maybe not...
651     #'billsec', #XXX duration and billsec?
652                 sub { $_[0]->billsec(  $_[1] );
653                       $_[0]->duration( $_[1] );
654                     },
655     'src',
656     'dst', # XXX needs to have "+61" prepended unless /^\+/ ???
657     'charged_party',
658     'upstream_currency',
659     'upstream_price',
660     'upstream_rateplanid',
661     'distance',
662     'islocal',
663     'calltypenum',
664     'startdate',  #XXX needs massaging
665     'enddate',    #XXX same
666     'description',
667     'quantity',
668     'carrierid',
669     'upstream_rateid',
670   ],
671   'voxlinesystems' => [ #XXX get the actual vendor name
672     'disposition',                        #Status
673     'startdate',                          #Start (what do you know, a timestamp!
674     sub { my($cdr, $field) = @_; },       #Start date
675     sub { my($cdr, $field) = @_; },       #Start time
676     'enddate',                            #End (also a timestamp!)
677     sub { my($cdr, $field) = @_; },       #End date
678     sub { my($cdr, $field) = @_; },       #End time
679     'accountcode',                        #Calling customer XXX map to agent_custid??
680     sub { my($cdr, $field) = @_; },       #Calling type
681     sub { shift->src('30000'); }, #XXX FAKE XXX 'src',                                #Calling number
682     'userfield',                          #Calling name #?
683     sub { my($cdr, $field) = @_; },       #Called type
684     'dst',                                #Called number
685     sub { my($cdr, $field) = @_; },       #Destination customer
686     sub { my($cdr, $field) = @_; },       #Destination type
687     sub { my($cdr, $field) = @_; },       #Destination Number
688     sub { my($cdr, $field) = @_; },       #Inbound calling type
689     sub { my($cdr, $field) = @_; },       #Inbound calling number
690     sub { my($cdr, $field) = @_; },       #Inbound called type
691     sub { my($cdr, $field) = @_; },       #Inbound called number
692     sub { my($cdr, $field) = @_; },       #Inbound destination type
693     sub { my($cdr, $field) = @_; },       #Inbound destination number
694     sub { my($cdr, $field) = @_; },       #Outbound calling type
695     sub { my($cdr, $field) = @_; },       #Outbound calling number
696     sub { my($cdr, $field) = @_; },       #Outbound called type
697     sub { my($cdr, $field) = @_; },       #Outbound called number
698     sub { my($cdr, $field) = @_; },       #Outbound destination type
699     sub { my($cdr, $field) = @_; },       #Outbound destination number
700     sub { my($cdr, $field) = @_; },       #Internal calling type
701     sub { my($cdr, $field) = @_; },       #Internal calling number
702     sub { my($cdr, $field) = @_; },       #Internal called type
703     sub { my($cdr, $field) = @_; },       #Internal called number
704     sub { my($cdr, $field) = @_; },       #Internal destination type
705     sub { my($cdr, $field) = @_; },       #Internal destination number
706     'duration',                           #Total seconds
707     sub { my($cdr, $field) = @_; },       #Ring seconds
708     'billsec',                            #Billable seconds
709     'upstream_price',                     #Cost
710     sub { my($cdr, $field) = @_; },       #Billing customer
711     sub { my($cdr, $field) = @_; },       #Billing customer name
712     sub { my($cdr, $field) = @_; },       #Billing type
713     sub { my($cdr, $field) = @_; },       #Billing reference
714   ],
715   'simple' => [
716
717     # Date
718     sub { my($cdr, $date) = @_;
719           $date =~ /^(\d{1,2})\/(\d{1,2})\/(\d\d(\d\d)?)$/
720             or die "unparsable date: $date"; #maybe we shouldn't die...
721           #$cdr->startdate( timelocal(0, 0, 0 ,$2, $1-1, $3) );
722           ($tmp_mday, $tmp_mon, $tmp_year) = ( $2, $1-1, $3 );
723         },
724
725     # Time
726     sub { my($cdr, $time) = @_;
727           #my($sec, $min, $hour, $mday, $mon, $year)= localtime($cdr->startdate);
728           $time =~ /^(\d{1,2}):(\d{1,2}):(\d{1,2})$/
729             or die "unparsable time: $time"; #maybe we shouldn't die...
730           #$cdr->startdate( timelocal($3, $2, $1 ,$mday, $mon, $year) );
731           $cdr->startdate(
732             timelocal($3, $2, $1 ,$tmp_mday, $tmp_mon, $tmp_year)
733           );
734         },
735
736     # Source_Number
737     'src',
738
739     # Terminating_Number
740     'dst',
741
742     # Duration
743     sub { my($cdr, $min) = @_;
744           my $sec = sprintf('%.0f', $min * 60 );
745           $cdr->billsec(  $sec );
746           $cdr->duration( $sec );
747         },
748
749   ],
750 );
751
752 my %import_header = (
753   'simple'         => 1,
754   'taqua'          => 1,
755   'voxlinesystems' => 2, #XXX vendor name
756 );
757
758 =item batch_import HASHREF
759
760 Imports CDR records.  Available options are:
761
762 =over 4
763
764 =item filehandle
765
766 =item format
767
768 =back
769
770 =cut
771
772 sub batch_import {
773   my $param = shift;
774
775   my $fh = $param->{filehandle};
776   my $format = $param->{format};
777
778   return "Unknown format $format" unless exists $import_formats{$format};
779
780   eval "use Text::CSV_XS;";
781   die $@ if $@;
782
783   my $csv = new Text::CSV_XS;
784
785   my $imported = 0;
786   #my $columns;
787
788   local $SIG{HUP} = 'IGNORE';
789   local $SIG{INT} = 'IGNORE';
790   local $SIG{QUIT} = 'IGNORE';
791   local $SIG{TERM} = 'IGNORE';
792   local $SIG{TSTP} = 'IGNORE';
793   local $SIG{PIPE} = 'IGNORE';
794
795   my $oldAutoCommit = $FS::UID::AutoCommit;
796   local $FS::UID::AutoCommit = 0;
797   my $dbh = dbh;
798
799   my $header_lines =
800     exists($import_header{$format}) ? $import_header{$format} : 0;
801
802   my $line;
803   while ( defined($line=<$fh>) ) {
804
805     next if $header_lines-- > 0; #&& $line =~ /^[\w, "]+$/ 
806
807     $csv->parse($line) or do {
808       $dbh->rollback if $oldAutoCommit;
809       return "can't parse: ". $csv->error_input();
810     };
811
812     my @columns = $csv->fields();
813     #warn join('-',@columns);
814
815     if ( $format eq 'simple' ) {
816       @columns = map { s/^ +//; $_; } @columns;
817     }
818
819     my @later = ();
820     my %cdr =
821       map {
822
823         my $field_or_sub = $_;
824         if ( ref($field_or_sub) ) {
825           push @later, $field_or_sub, shift(@columns);
826           ();
827         } else {
828           ( $field_or_sub => shift @columns );
829         }
830
831       }
832       @{ $import_formats{$format} }
833     ;
834
835     my $cdr = new FS::cdr ( \%cdr );
836
837     while ( scalar(@later) ) {
838       my $sub = shift @later;
839       my $data = shift @later;
840       &{$sub}($cdr, $data);  # $cdr->&{$sub}($data); 
841     }
842
843     if ( $format eq 'taqua' ) {
844       if ( $cdr->enddate && $cdr->startdate  ) { #a bit more?
845         $cdr->duration( $cdr->enddate - $cdr->startdate  );
846       }
847       if ( $cdr->enddate && $cdr->answerdate ) { #a bit more?
848         $cdr->billsec(  $cdr->enddate - $cdr->answerdate );
849       } 
850     }
851
852     my $error = $cdr->insert;
853     if ( $error ) {
854       $dbh->rollback if $oldAutoCommit;
855       return $error;
856
857       #or just skip?
858       #next;
859     }
860
861     $imported++;
862   }
863
864   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
865
866   #might want to disable this if we skip records for any reason...
867   return "Empty file!" unless $imported;
868
869   '';
870
871 }
872
873 =back
874
875 =head1 BUGS
876
877 =head1 SEE ALSO
878
879 L<FS::Record>, schema.html from the base documentation.
880
881 =cut
882
883 1;
884