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