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