b7310fe9c09e90a0d7686b54f9deb0e8cad30447
[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 _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 =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_min_parser_maker {
551   my $field = shift;
552   my @fields = ref($field) ? @$field : ($field);
553   return sub {
554     my( $cdr, $min ) = @_;
555     my $sec = eval { _cdr_min_parse($min) };
556     die "error parsing seconds for @fields from $min minutes: $@\n" if $@;
557     $cdr->$_($sec) foreach @fields;
558   };
559 }
560
561 sub _cdr_min_parse {
562   my $min = shift;
563   sprintf('%.0f', $min * 60 );
564 }
565
566 sub _cdr_date_parser_maker {
567   my $field = shift || [qw( billsec duration )];
568   return sub {
569     my( $cdr, $date ) = @_;
570     #$cdr->$field( _cdr_date_parse($date) );
571     eval { $cdr->$field( _cdr_date_parse($date) ); };
572     die "error parsing date for $field from $date: $@\n" if $@;
573   };
574 }
575
576 sub _cdr_date_parse {
577   my $date = shift;
578
579   return '' unless length($date); #that's okay, it becomes NULL
580
581   my($year, $mon, $day, $hour, $min, $sec);
582
583   #$date =~ /^\s*(\d{4})[\-\/]\(\d{1,2})[\-\/](\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})\s*$/
584   #taqua  #2007-10-31 08:57:24.113000000
585
586   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|$)/ ) {
587     ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
588   } 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|$)/ ) {
589     ($mon, $day, $year, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
590   } else {
591      die "unparsable date: $date"; #maybe we shouldn't die...
592   }
593
594   return '' if $year == 1900 && $mon == 1 && $day == 1
595             && $hour == 0    && $min == 0 && $sec == 0;
596
597   timelocal($sec, $min, $hour, $day, $mon-1, $year);
598 }
599
600 =item batch_import HASHREF
601
602 Imports CDR records.  Available options are:
603
604 =over 4
605
606 =item filehandle
607
608 =item format
609
610 =back
611
612 =cut
613
614 sub batch_import {
615   my $param = shift;
616
617   my $fh = $param->{filehandle};
618   my $format = $param->{format};
619
620   return "Unknown format $format"
621     unless exists( $cdr_info{$format} )
622         && exists( $cdr_info{$format}->{'import_fields'} );
623
624   my $info = $cdr_info{$format};
625
626   my $type = exists($info->{'type'}) ? lc($info->{'type'}) : 'csv';
627
628   my $parser;
629   if ( $type eq 'csv' ) {
630     eval "use Text::CSV_XS;";
631     die $@ if $@;
632     $parser = new Text::CSV_XS;
633   } elsif ( $type eq 'fixedlength' ) {
634     eval "use Parse::FixedLength;";
635     die $@ if $@;
636     $parser = new Parse::FixedLength $info->{'fixedlength_format'};
637   } else {
638     die "Unknown CDR format type $type for format $format\n";
639   }
640
641   my $imported = 0;
642   #my $columns;
643
644   local $SIG{HUP} = 'IGNORE';
645   local $SIG{INT} = 'IGNORE';
646   local $SIG{QUIT} = 'IGNORE';
647   local $SIG{TERM} = 'IGNORE';
648   local $SIG{TSTP} = 'IGNORE';
649   local $SIG{PIPE} = 'IGNORE';
650
651   my $oldAutoCommit = $FS::UID::AutoCommit;
652   local $FS::UID::AutoCommit = 0;
653   my $dbh = dbh;
654
655   my $header_lines = exists($info->{'header'}) ? $info->{'header'} : 0;
656
657   my $line;
658   while ( defined($line=<$fh>) ) {
659
660     next if $header_lines-- > 0; #&& $line =~ /^[\w, "]+$/ 
661
662     my @columns = ();
663     if ( $type eq 'csv' ) {
664
665       $parser->parse($line) or do {
666         $dbh->rollback if $oldAutoCommit;
667         return "can't parse: ". $parser->error_input();
668       };
669
670       @columns = $parser->fields();
671
672     } elsif ( $type eq 'fixedlength' ) {
673
674       @columns = $parser->parse($line);
675
676     } else {
677       die "Unknown CDR format type $type for format $format\n";
678     }
679
680     #warn join('-',@columns);
681
682     if ( $format eq 'simple' ) { #should be a callback or opt in FS::cdr::simple
683       @columns = map { s/^ +//; $_; } @columns;
684     }
685
686     my @later = ();
687     my %cdr =
688       map {
689
690         my $field_or_sub = $_;
691         if ( ref($field_or_sub) ) {
692           push @later, $field_or_sub, shift(@columns);
693           ();
694         } else {
695           ( $field_or_sub => shift @columns );
696         }
697
698       }
699       @{ $info->{'import_fields'} }
700     ;
701
702     my $cdr = new FS::cdr ( \%cdr );
703
704     while ( scalar(@later) ) {
705       my $sub = shift @later;
706       my $data = shift @later;
707       &{$sub}($cdr, $data);  # $cdr->&{$sub}($data); 
708     }
709
710     if ( $format eq 'taqua' ) { #should be a callback or opt in FS::cdr::taqua
711       if ( $cdr->enddate && $cdr->startdate  ) { #a bit more?
712         $cdr->duration( $cdr->enddate - $cdr->startdate  );
713       }
714       if ( $cdr->enddate && $cdr->answerdate ) { #a bit more?
715         $cdr->billsec(  $cdr->enddate - $cdr->answerdate );
716       } 
717     }
718
719     my $error = $cdr->insert;
720     if ( $error ) {
721       $dbh->rollback if $oldAutoCommit;
722       return $error;
723
724       #or just skip?
725       #next;
726     }
727
728     $imported++;
729   }
730
731   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
732
733   #might want to disable this if we skip records for any reason...
734   return "Empty file!" unless $imported;
735
736   '';
737
738 }
739
740 =back
741
742 =head1 BUGS
743
744 =head1 SEE ALSO
745
746 L<FS::Record>, schema.html from the base documentation.
747
748 =cut
749
750 1;
751