respect output_format and add an header for rating_method=prefix too, RT#4387
[freeside.git] / FS / FS / cdr.pm
index 5eb0cf3..27db89e 100644 (file)
@@ -1,10 +1,14 @@
 package FS::cdr;
 
 use strict;
-use vars qw( @ISA );
+use vars qw( @ISA @EXPORT_OK $DEBUG );
+use Exporter;
+use Tie::IxHash;
 use Date::Parse;
 use Date::Format;
+use Time::Local;
 use FS::UID qw( dbh );
+use FS::Conf;
 use FS::Record qw( qsearch qsearchs );
 use FS::cdr_type;
 use FS::cdr_calltype;
@@ -12,6 +16,9 @@ use FS::cdr_carrier;
 use FS::cdr_upstream_rate;
 
 @ISA = qw(FS::Record);
+@EXPORT_OK = qw( _cdr_date_parser_maker _cdr_min_parser_maker );
+
+$DEBUG = 0;
 
 =head1 NAME
 
@@ -83,8 +90,6 @@ following fields are currently supported:
   #billing: Mark the entry for billing 
   #documentation: Mark the entry for documentation.
 
-=back
-
 =item accountcode - CDR account number to use: account
 
 =item uniqueid - Unique channel identifier (Unitel/RSLCOM Event ID)
@@ -125,6 +130,8 @@ following fields are currently supported:
 
 =item freesidestatus - NULL, done (or something)
 
+=item cdrbatch
+
 =back
 
 =head1 METHODS
@@ -224,19 +231,44 @@ sub check {
 #  ;
 #  return $error if $error;
 
+  $self->calldate( $self->startdate_sql )
+    if !$self->calldate && $self->startdate;
+
+  my $conf = new FS::Conf;
+
+  unless ( $self->charged_party ) {
+
+    if ( $conf->exists('cdr-charged_party-accountcode') && $self->accountcode ){
+
+      $self->charged_party( $self->accountcode );
+
+    } else {
+
+      if ( $self->dst =~ /^(\+?1)?8[02-8]{2}/ ) {
+        $self->charged_party($self->dst);
+      } else {
+        $self->charged_party($self->src);
+      }
+
+    }
+
+  }
+
   #check the foreign keys even?
   #do we want to outright *reject* the CDR?
   my $error =
        $self->ut_numbern('acctid')
 
-    #Usage = 1, S&E = 7, OC&C = 8
-    || $self->ut_foreign_keyn('cdrtypenum',  'cdr_type',     'cdrtypenum' )
-
-    #the big list in appendix 2
-    || $self->ut_foreign_keyn('calltypenum', 'cdr_calltype', 'calltypenum' )
-
-    # Telstra =1, Optus = 2, RSL COM = 3
-    || $self->ut_foreign_keyn('carrierid', 'cdr_carrier', 'carrierid' )
+  #add a config option to turn these back on if someone needs 'em
+  #
+  #  #Usage = 1, S&E = 7, OC&C = 8
+  #  || $self->ut_foreign_keyn('cdrtypenum',  'cdr_type',     'cdrtypenum' )
+  #
+  #  #the big list in appendix 2
+  #  || $self->ut_foreign_keyn('calltypenum', 'cdr_calltype', 'calltypenum' )
+  #
+  #  # Telstra =1, Optus = 2, RSL COM = 3
+  #  || $self->ut_foreign_keyn('carrierid', 'cdr_carrier', 'carrierid' )
   ;
   return $error if $error;
 
@@ -252,7 +284,7 @@ error, otherwise returns false.
 
 sub set_status_and_rated_price {
   my($self, $status, $rated_price) = @_;
-  $self->status($status);
+  $self->freesidestatus($status);
   $self->rated_price($rated_price);
   $self->replace();
 }
@@ -267,6 +299,20 @@ sub calldate_unix {
   str2time(shift->calldate);
 }
 
+=item startdate_sql
+
+Parses the startdate in UNIX timestamp format and returns a string in SQL
+format.
+
+=cut
+
+sub startdate_sql {
+  my($sec,$min,$hour,$mday,$mon,$year) = localtime(shift->startdate);
+  $mon++;
+  $year += 1900;
+  "$year-$mon-$mday $hour:$min:$sec";
+}
+
 =item cdr_carrier
 
 Returns the FS::cdr_carrier object associated with this CDR, or false if no
@@ -368,6 +414,27 @@ sub _convergent_format {
 
 =cut
 
+my %export_names = (
+  'convergent'      => {},
+  'simple'  => {
+    'name'           => 'Simple',
+    'invoice_header' => "Date,Time,Name,Destination,Duration,Price",
+  },
+  'simple2' => {
+    'name'           => 'Simple with source',
+    'invoice_header' => "Date,Time,Called From,Destination,Duration,Price",
+                       #"Date,Time,Name,Called From,Destination,Duration,Price",
+  },
+  'default' => {
+    'name'           => 'Default',
+    'invoice_header' => 'Date,Time,Duration,Price,Number,Destination',
+  },
+  'source_default' => {
+    'name'           => 'Default with source',
+    'invoice_header' => 'Caller,Date,Time,Duration,Price,Number,Destination',
+  },
+);
+
 my %export_formats = (
   'convergent' => [
     'carriername', #CARRIER
@@ -384,7 +451,50 @@ my %export_formats = (
     sub { shift->rated_price ? 'Y' : 'N' }, #RATED
     '', #OTHER_INFO
   ],
+  'simple' => [
+    sub { time2str('%D', shift->calldate_unix ) },   #DATE
+    sub { time2str('%r', shift->calldate_unix ) },   #TIME
+    'userfield',                                     #USER
+    'dst',                                           #NUMBER_DIALED
+    sub { sprintf('%.2fm', shift->billsec / 60 ) },  #DURATION
+    sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
+  ],
+  'simple2' => [
+    sub { time2str('%D', shift->calldate_unix ) },   #DATE
+    sub { time2str('%r', shift->calldate_unix ) },   #TIME
+    #'userfield',                                     #USER
+    'dst',                                           #NUMBER_DIALED
+    'src',                                           #called from
+    sub { sprintf('%.2fm', shift->billsec / 60 ) },  #DURATION
+    sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
+  ],
+  'default' => [
+
+    #DATE
+    sub { time2str('%D', shift->calldate_unix ) },
+          # #time2str("%Y %b %d - %r", $cdr->calldate_unix ),
+
+    #TIME
+    sub { time2str('%r', shift->calldate_unix ) },
+          # time2str("%c", $cdr->calldate_unix),  #XXX this should probably be a config option dropdown so they can select US vs- rest of world dates or whatnot
+
+    #DURATION
+    sub { my($cdr, %opt) = @_;
+          $opt{minutes}. ( $opt{granularity} ? 'm' : ' call' );
+        },
+
+    #PRICE
+    sub { my($cdr, %opt) = @_; $opt{money_char}. $opt{charge}; },
+
+    #DEST ("Number")
+    sub { my($cdr, %opt) = @_; $opt{pretty_dst} || $cdr->dst; },
+
+    #REGIONNAME ("Destination")
+    sub { my($cdr, %opt) = @_; $opt{dst_regionname}; },
+
+  ],
 );
+$export_formats{'source_default'} = [ 'src', @{ $export_formats{'default'} } ];
 
 sub downstream_csv {
   my( $self, %opt ) = @_;
@@ -392,13 +502,17 @@ sub downstream_csv {
   my $format = $opt{'format'}; # 'convergent';
   return "Unknown format $format" unless exists $export_formats{$format};
 
+  #my $conf = new FS::Conf;
+  #$opt{'money_char'} ||= $conf->config('money_char') || '$';
+  $opt{'money_char'} ||= FS::Conf->new->config('money_char') || '$';
+
   eval "use Text::CSV_XS;";
   die $@ if $@;
   my $csv = new Text::CSV_XS;
 
   my @columns =
     map {
-          ref($_) ? &{$_}($self) : $self->$_();
+          ref($_) ? &{$_}($self, %opt) : $self->$_();
         }
     @{ $export_formats{$format} };
 
@@ -416,70 +530,174 @@ sub downstream_csv {
 
 =over 4
 
-=item batch_import
+=item invoice_formats
+
+Returns an ordered list of key value pairs containing invoice format names
+as keys (for use with part_pkg::voip_cdr) and "pretty" format names as values.
 
 =cut
 
-my %import_formats = (
-  'asterisk' => [
-    'accountcode',
-    'src',
-    'dst',
-    'dcontext',
-    'clid',
-    'channel',
-    'dstchannel',
-    'lastapp',
-    'lastdata',
-    'startdate', # XXX will need massaging
-    'answer',    # XXX same
-    'end',       # XXX same
-    'duration',
-    'billsec',
-    'disposition',
-    'amaflags',
-    'uniqueid',
-    'userfield',
-  ],
-  'unitel' => [
-    'uniqueid',
-    #'cdr_type',
-    'cdrtypenum',
-    'calldate', # may need massaging?  huh maybe not...
-    #'billsec', #XXX duration and billsec?
-                sub { $_[0]->billsec(  $_[1] );
-                      $_[0]->duration( $_[1] );
-                    },
-    'src',
-    'dst', # XXX needs to have "+61" prepended unless /^\+/ ???
-    'charged_party',
-    'upstream_currency',
-    'upstream_price',
-    'upstream_rateplanid',
-    'distance',
-    'islocal',
-    'calltypenum',
-    'startdate',  #XXX needs massaging
-    'enddate',    #XXX same
-    'description',
-    'quantity',
-    'carrierid',
-    'upstream_rateid',
-  ]
-);
+sub invoice_formats {
+  map { ($_ => $export_names{$_}->{'name'}) }
+    grep { $export_names{$_}->{'invoice_header'} }
+    keys %export_names;
+}
+
+=item invoice_header FORMAT
+
+Returns a scalar containing the CSV column header for invoice format FORMAT.
+
+=cut
+
+sub invoice_header {
+  my $format = shift;
+  $export_names{$format}->{'invoice_header'};
+}
+
+=item import_formats
+
+Returns an ordered list of key value pairs containing import format names
+as keys (for use with batch_import) and "pretty" format names as values.
+
+=cut
+
+#false laziness w/part_pkg & part_export
+
+my %cdr_info;
+foreach my $INC ( @INC ) {
+  warn "globbing $INC/FS/cdr/*.pm\n" if $DEBUG;
+  foreach my $file ( glob("$INC/FS/cdr/*.pm") ) {
+    warn "attempting to load CDR format info from $file\n" if $DEBUG;
+    $file =~ /\/(\w+)\.pm$/ or do {
+      warn "unrecognized file in $INC/FS/cdr/: $file\n";
+      next;
+    };
+    my $mod = $1;
+    my $info = eval "use FS::cdr::$mod; ".
+                    "\\%FS::cdr::$mod\::info;";
+    if ( $@ ) {
+      die "error using FS::cdr::$mod (skipping): $@\n" if $@;
+      next;
+    }
+    unless ( keys %$info ) {
+      warn "no %info hash found in FS::cdr::$mod, skipping\n";
+      next;
+    }
+    warn "got CDR format info from FS::cdr::$mod: $info\n" if $DEBUG;
+    if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
+      warn "skipping disabled CDR format FS::cdr::$mod" if $DEBUG;
+      next;
+    }
+    $cdr_info{$mod} = $info;
+  }
+}
+
+tie my %import_formats, 'Tie::IxHash',
+  map  { $_ => $cdr_info{$_}->{'name'} }
+  sort { $cdr_info{$a}->{'weight'} <=> $cdr_info{$b}->{'weight'} }
+  grep { exists($cdr_info{$_}->{'import_fields'}) }
+  keys %cdr_info;
+
+sub import_formats {
+  %import_formats;
+}
+
+sub _cdr_min_parser_maker {
+  my $field = shift;
+  my @fields = ref($field) ? @$field : ($field);
+  @fields = qw( billsec duration ) unless scalar(@fields) && $fields[0];
+  return sub {
+    my( $cdr, $min ) = @_;
+    my $sec = eval { _cdr_min_parse($min) };
+    die "error parsing seconds for @fields from $min minutes: $@\n" if $@;
+    $cdr->$_($sec) foreach @fields;
+  };
+}
+
+sub _cdr_min_parse {
+  my $min = shift;
+  sprintf('%.0f', $min * 60 );
+}
+
+sub _cdr_date_parser_maker {
+  my $field = shift;
+  return sub {
+    my( $cdr, $date ) = @_;
+    #$cdr->$field( _cdr_date_parse($date) );
+    eval { $cdr->$field( _cdr_date_parse($date) ); };
+    die "error parsing date for $field from $date: $@\n" if $@;
+  };
+}
+
+sub _cdr_date_parse {
+  my $date = shift;
+
+  return '' unless length($date); #that's okay, it becomes NULL
+
+  my($year, $mon, $day, $hour, $min, $sec);
+
+  #$date =~ /^\s*(\d{4})[\-\/]\(\d{1,2})[\-\/](\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})\s*$/
+  #taqua  #2007-10-31 08:57:24.113000000
+
+  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|$)/ ) {
+    ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
+  } 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|$)/ ) {
+    ($mon, $day, $year, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
+  } else {
+     die "unparsable date: $date"; #maybe we shouldn't die...
+  }
+
+  return '' if $year == 1900 && $mon == 1 && $day == 1
+            && $hour == 0    && $min == 0 && $sec == 0;
+
+  timelocal($sec, $min, $hour, $day, $mon-1, $year);
+}
+
+=item batch_import HASHREF
+
+Imports CDR records.  Available options are:
+
+=over 4
+
+=item filehandle
+
+=item format
+
+=back
+
+=cut
 
 sub batch_import {
   my $param = shift;
 
   my $fh = $param->{filehandle};
   my $format = $param->{format};
+  my $cdrbatch = $param->{cdrbatch};
 
-  return "Unknown format $format" unless exists $import_formats{$format};
+  return "Unknown format $format"
+    unless exists( $cdr_info{$format} )
+        && exists( $cdr_info{$format}->{'import_fields'} );
 
-  eval "use Text::CSV_XS;";
-  die $@ if $@;
+  my $info = $cdr_info{$format};
 
-  my $csv = new Text::CSV_XS;
+  my $type = exists($info->{'type'}) ? lc($info->{'type'}) : 'csv';
+
+  my $parser;
+  if ( $type eq 'csv' ) {
+    eval "use Text::CSV_XS;";
+    die $@ if $@;
+    my %attr = ();
+    foreach ( grep exists($info->{$_}), qw( sep_char ) ) {
+      $attr{$_} = $info->{$_};
+    }
+    $parser = new Text::CSV_XS \%attr;
+  } elsif ( $type eq 'fixedlength' ) {
+    eval "use Parse::FixedLength;";
+    die $@ if $@;
+    $parser = new Parse::FixedLength $info->{'fixedlength_format'};
+  } else {
+    die "Unknown CDR format type $type for format $format\n";
+  }
 
   my $imported = 0;
   #my $columns;
@@ -494,18 +712,38 @@ sub batch_import {
   my $oldAutoCommit = $FS::UID::AutoCommit;
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
-  
+
+  my $header_lines = exists($info->{'header'}) ? $info->{'header'} : 0;
+
   my $line;
   while ( defined($line=<$fh>) ) {
 
-    $csv->parse($line) or do {
-      $dbh->rollback if $oldAutoCommit;
-      return "can't parse: ". $csv->error_input();
-    };
+    next if $header_lines-- > 0; #&& $line =~ /^[\w, "]+$/ 
+
+    my @columns = ();
+    if ( $type eq 'csv' ) {
+
+      $parser->parse($line) or do {
+        $dbh->rollback if $oldAutoCommit;
+        return "can't parse: ". $parser->error_input();
+      };
+
+      @columns = $parser->fields();
+
+    } elsif ( $type eq 'fixedlength' ) {
+
+      @columns = $parser->parse($line);
+
+    } else {
+      die "Unknown CDR format type $type for format $format\n";
+    }
 
-    my @columns = $csv->fields();
     #warn join('-',@columns);
 
+    if ( $format eq 'simple' ) { #should be a callback or opt in FS::cdr::simple
+      @columns = map { s/^ +//; $_; } @columns;
+    }
+
     my @later = ();
     my %cdr =
       map {
@@ -519,8 +757,10 @@ sub batch_import {
         }
 
       }
-      @{ $import_formats{$format} }
+      @{ $info->{'import_fields'} }
     ;
+    $cdr{cdrbatch} = $cdrbatch;
 
     my $cdr = new FS::cdr ( \%cdr );
 
@@ -530,6 +770,15 @@ sub batch_import {
       &{$sub}($cdr, $data);  # $cdr->&{$sub}($data); 
     }
 
+    if ( $format eq 'taqua' ) { #should be a callback or opt in FS::cdr::taqua
+      if ( $cdr->enddate && $cdr->startdate  ) { #a bit more?
+        $cdr->duration( $cdr->enddate - $cdr->startdate  );
+      }
+      if ( $cdr->enddate && $cdr->answerdate ) { #a bit more?
+        $cdr->billsec(  $cdr->enddate - $cdr->answerdate );
+      } 
+    }
+
     my $error = $cdr->insert;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
@@ -545,7 +794,7 @@ sub batch_import {
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   #might want to disable this if we skip records for any reason...
-  return "Empty file!" unless $imported;
+  return "Empty file!" unless $imported || $param->{empty_ok};
 
   '';