rate tiers for vnes, RT#14903
[freeside.git] / FS / FS / cdr.pm
index adde898..850f797 100644 (file)
@@ -1,18 +1,26 @@
 package FS::cdr;
 
 use strict;
 package FS::cdr;
 
 use strict;
-use vars qw( @ISA );
+use vars qw( @ISA @EXPORT_OK $DEBUG $me );
+use Exporter;
+use Tie::IxHash;
 use Date::Parse;
 use Date::Format;
 use Time::Local;
 use FS::UID qw( dbh );
 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;
 use FS::cdr_carrier;
 use FS::Record qw( qsearch qsearchs );
 use FS::cdr_type;
 use FS::cdr_calltype;
 use FS::cdr_carrier;
-use FS::cdr_upstream_rate;
+use FS::cdr_batch;
+use FS::cdr_termination;
 
 @ISA = qw(FS::Record);
 
 @ISA = qw(FS::Record);
+@EXPORT_OK = qw( _cdr_date_parser_maker _cdr_min_parser_maker );
+
+$DEBUG = 0;
+$me = '[FS::cdr]';
 
 =head1 NAME
 
 
 =head1 NAME
 
@@ -124,6 +132,10 @@ following fields are currently supported:
 
 =item freesidestatus - NULL, done (or something)
 
 
 =item freesidestatus - NULL, done (or something)
 
+=item freesiderewritestatus - NULL, done (or something)
+
+=item cdrbatch
+
 =back
 
 =head1 METHODS
 =back
 
 =head1 METHODS
@@ -143,6 +155,55 @@ points to.  You can ask the object for a copy with the I<hash> method.
 
 sub table { 'cdr'; }
 
 
 sub table { 'cdr'; }
 
+sub table_info {
+  {
+    'fields' => {
+#XXX fill in some (more) nice names
+        #'acctid'                => '',
+        'calldate'              => 'Call date',
+        'clid'                  => 'Caller ID',
+        'src'                   => 'Source',
+        'dst'                   => 'Destination',
+        'dcontext'              => 'Dest. context',
+        'channel'               => 'Channel',
+        'dstchannel'            => 'Destination channel',
+        #'lastapp'               => '',
+        #'lastdata'              => '',
+        'startdate'             => 'Start date',
+        'answerdate'            => 'Answer date',
+        'enddate'               => 'End date',
+        'duration'              => 'Duration',
+        'billsec'               => 'Billable seconds',
+        'disposition'           => 'Disposition',
+        'amaflags'              => 'AMA flags',
+        'accountcode'           => 'Account code',
+        #'uniqueid'              => '',
+        'userfield'             => 'User field',
+        #'cdrtypenum'            => '',
+        'charged_party'         => 'Charged party',
+        #'upstream_currency'     => '',
+        'upstream_price'        => 'Upstream price',
+        #'upstream_rateplanid'   => '',
+        #'ratedetailnum'         => '',
+        'rated_price'           => 'Rated price',
+        #'distance'              => '',
+        #'islocal'               => '',
+        #'calltypenum'           => '',
+        #'description'           => '',
+        #'quantity'              => '',
+        'carrierid'             => 'Carrier ID',
+        #'upstream_rateid'       => '',
+        'svcnum'                => 'Freeside service',
+        'freesidestatus'        => 'Freeside status',
+        'freesiderewritestatus' => 'Freeside rewrite status',
+        'cdrbatch'              => 'Legacy batch',
+        'cdrbatchnum'           => 'Batch',
+    },
+
+  };
+
+}
+
 =item insert
 
 Adds this record to the database.  If there is an error, returns the error,
 =item insert
 
 Adds this record to the database.  If there is an error, returns the error,
@@ -220,40 +281,118 @@ sub check {
 #    || $self->ut_numbern('upstream_rateid')
 #    || $self->ut_numbern('svcnum')
 #    || $self->ut_textn('freesidestatus')
 #    || $self->ut_numbern('upstream_rateid')
 #    || $self->ut_numbern('svcnum')
 #    || $self->ut_textn('freesidestatus')
+#    || $self->ut_textn('freesiderewritestatus')
 #  ;
 #  return $error if $error;
 
 #  ;
 #  return $error if $error;
 
+  for my $f ( grep { $self->$_ =~ /\D/ } qw(startdate answerdate enddate)){
+    $self->$f( str2time($self->$f) );
+  }
+
   $self->calldate( $self->startdate_sql )
     if !$self->calldate && $self->startdate;
 
   $self->calldate( $self->startdate_sql )
     if !$self->calldate && $self->startdate;
 
-  unless ( $self->charged_party ) {
-    if ( $self->dst =~ /^(\+?1)?8[02-8]{2}/ ) {
-      $self->charged_party($self->dst);
-    } else {
-      $self->charged_party($self->src);
-    }
+  #was just for $format eq 'taqua' but can't see the harm... add something to
+  #disable if it becomes a problem
+  if ( $self->duration eq '' && $self->enddate && $self->startdate ) {
+    $self->duration( $self->enddate - $self->startdate  );
   }
   }
+  if ( $self->billsec eq '' && $self->enddate && $self->answerdate ) {
+    $self->billsec(  $self->enddate - $self->answerdate );
+  } 
+
+  $self->set_charged_party;
 
   #check the foreign keys even?
   #do we want to outright *reject* the CDR?
   my $error =
        $self->ut_numbern('acctid')
 
 
   #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;
 
   $self->SUPER::check;
 }
 
   ;
   return $error if $error;
 
   $self->SUPER::check;
 }
 
-=item set_status_and_rated_price STATUS [ RATED_PRICE ]
+=item is_tollfree [ COLUMN ]
+
+Returns true when the cdr represents a toll free number and false otherwise.
+
+By default, inspects the dst field, but an optional column name can be passed
+to inspect other field.
+
+=cut
+
+sub is_tollfree {
+  my $self = shift;
+  my $field = scalar(@_) ? shift : 'dst';
+  ( $self->$field() =~ /^(\+?1)?8(8|([02-7])\3)/ ) ? 1 : 0;
+}
+
+=item set_charged_party
+
+If the charged_party field is already set, does nothing.  Otherwise:
+
+If the cdr-charged_party-accountcode config option is enabled, sets the
+charged_party to the accountcode.
+
+Otherwise sets the charged_party normally: to the src field in most cases,
+or to the dst field if it is a toll free number.
+
+=cut
+
+sub set_charged_party {
+  my $self = shift;
+
+  my $conf = new FS::Conf;
+
+  unless ( $self->charged_party ) {
+
+    if ( $conf->exists('cdr-charged_party-accountcode') && $self->accountcode ){
+
+      my $charged_party = $self->accountcode;
+      $charged_party =~ s/^0+//
+        if $conf->exists('cdr-charged_party-accountcode-trim_leading_0s');
+      $self->charged_party( $charged_party );
+
+    } elsif ( $conf->exists('cdr-charged_party-field') ) {
+
+      my $field = $conf->config('cdr-charged_party-field');
+      $self->charged_party( $self->$field() );
+
+    } else {
+
+      if ( $self->is_tollfree ) {
+        $self->charged_party($self->dst);
+      } else {
+        $self->charged_party($self->src);
+      }
+
+    }
+
+  }
+
+#  my $prefix = $conf->config('cdr-charged_party-truncate_prefix');
+#  my $prefix_len = length($prefix);
+#  my $trunc_len = $conf->config('cdr-charged_party-truncate_length');
+#
+#  $self->charged_party( substr($self->charged_party, 0, $trunc_len) )
+#    if $prefix_len && $trunc_len
+#    && substr($self->charged_party, 0, $prefix_len) eq $prefix;
+
+}
+
+=item set_status_and_rated_price STATUS [ RATED_PRICE [ SVCNUM ] ]
 
 Sets the status to the provided string.  If there is an error, returns the
 error, otherwise returns false.
 
 Sets the status to the provided string.  If there is an error, returns the
 error, otherwise returns false.
@@ -261,10 +400,41 @@ error, otherwise returns false.
 =cut
 
 sub set_status_and_rated_price {
 =cut
 
 sub set_status_and_rated_price {
-  my($self, $status, $rated_price) = @_;
-  $self->freesidestatus($status);
-  $self->rated_price($rated_price);
-  $self->replace();
+  my($self, $status, $rated_price, $svcnum, %opt) = @_;
+
+  if ($opt{'inbound'}) {
+
+    my $term = qsearchs('cdr_termination', {
+        acctid   => $self->acctid, 
+        termpart => 1 # inbound
+    });
+    my $error;
+    if ( $term ) {
+      warn "replacing existing cdr status (".$self->acctid.")\n" if $term;
+      $error = $term->delete;
+      return $error if $error;
+    }
+    $term = FS::cdr_termination->new({
+        acctid      => $self->acctid,
+        termpart    => 1,
+        rated_price => $rated_price,
+        status      => $status,
+        svcnum      => $svcnum,
+    });
+    $term->rated_seconds($opt{rated_seconds}) if exists($opt{rated_seconds});
+    $term->rated_minutes($opt{rated_minutes}) if exists($opt{rated_minutes});
+    return $term->insert;
+
+  } else {
+
+    $self->freesidestatus($status);
+    $self->rated_price($rated_price);
+    $self->rated_seconds($opt{rated_seconds}) if exists($opt{rated_seconds});
+    $self->rated_minutes($opt{rated_minutes}) if exists($opt{rated_minutes});
+    $self->svcnum($svcnum) if $svcnum;
+    return $self->replace();
+
+  }
 }
 
 =item calldate_unix 
 }
 
 =item calldate_unix 
@@ -349,101 +519,161 @@ sub calltypename {
   $cdr_calltype ? $cdr_calltype->calltypename : '';
 }
 
   $cdr_calltype ? $cdr_calltype->calltypename : '';
 }
 
-=item cdr_upstream_rate
-
-Returns the upstream rate mapping (see L<FS::cdr_upstream_rate>), or the empty
-string if no FS::cdr_upstream_rate object is associated with this CDR.
+=item downstream_csv [ OPTION => VALUE, ... ]
 
 =cut
 
 
 =cut
 
-sub cdr_upstream_rate {
-  my $self = shift;
-  return '' unless $self->upstream_rateid;
-  qsearchs('cdr_upstream_rate', { 'upstream_rateid' => $self->upstream_rateid })
-    or '';
-}
-
-=item _convergent_format COLUMN [ COUNTRYCODE ]
+my %export_names = (
+  '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",
+  },
+  'basic' => {
+    'name'           => 'Basic',
+    'invoice_header' => "Date/Time,Called Number,Min/Sec,Price",
+  },
+  'default' => {
+    'name'           => 'Default',
+    'invoice_header' => 'Date,Time,Number,Destination,Duration,Price',
+  },
+  'source_default' => {
+    'name'           => 'Default with source',
+    'invoice_header' => 'Caller,Date,Time,Number,Destination,Duration,Price',
+  },
+  'accountcode_default' => {
+    'name'           => 'Default plus accountcode',
+    'invoice_header' => 'Date,Time,Account,Number,Destination,Duration,Price',
+  },
+  'description_default' => {
+    'name'           => 'Default with description field as destination',
+    'invoice_header' => 'Caller,Date,Time,Number,Destination,Duration,Price',
+  },
+);
 
 
-Returns the number in COLUMN formatted as follows:
+my %export_formats = ();
+sub export_formats {
+  #my $self = shift;
 
 
-If the country code does not match COUNTRYCODE (default "61"), it is returned
-unchanged.
+  return %export_formats if keys %export_formats;
 
 
-If the country code does match COUNTRYCODE (default "61"), it is removed.  In
-addiiton, "0" is prepended unless the number starts with 13, 18 or 19. (???)
+  my $conf = new FS::Conf;
+  my $date_format = $conf->config('date_format') || '%m/%d/%Y';
 
 
-=cut
+  # call duration in the largest units that accurately reflect the  granularity
+  my $duration_sub = sub {
+    my($cdr, %opt) = @_;
+    my $sec = $opt{seconds} || $cdr->billsec;
+    if ( defined $opt{granularity} && 
+         $opt{granularity} == 0 ) { #per call
+      return '1 call';
+    }
+    elsif ( defined $opt{granularity} && $opt{granularity} == 60 ) {#full minutes
+      my $min = int($sec/60);
+      $min++ if $sec%60;
+      return $min.'m';
+    }
+    else { #anything else
+      return sprintf("%dm %ds", $sec/60, $sec%60);
+    }
+  };
 
 
-sub _convergent_format {
-  my( $self, $field ) = ( shift, shift );
-  my $countrycode = scalar(@_) ? shift : '61'; #+61 = australia
-  #my $number = $self->$field();
-  my $number = $self->get($field);
-  #if ( $number =~ s/^(\+|011)$countrycode// ) {
-  if ( $number =~ s/^\+$countrycode// ) {
-    $number = "0$number"
-      unless $number =~ /^1[389]/; #???
-  }
-  $number;
+  %export_formats = (
+    'simple' => [
+      sub { time2str($date_format, shift->calldate_unix ) },   #DATE
+      sub { time2str('%r', shift->calldate_unix ) },   #TIME
+      'userfield',                                     #USER
+      'dst',                                           #NUMBER_DIALED
+      $duration_sub,                                   #DURATION
+      #sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
+      sub { my($cdr, %opt) = @_; $opt{money_char}. $opt{charge}; }, #PRICE
+    ],
+    'simple2' => [
+      sub { time2str($date_format, shift->calldate_unix ) },   #DATE
+      sub { time2str('%r', shift->calldate_unix ) },   #TIME
+      #'userfield',                                     #USER
+      'src',                                           #called from
+      'dst',                                           #NUMBER_DIALED
+      $duration_sub,                                   #DURATION
+      #sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
+      sub { my($cdr, %opt) = @_; $opt{money_char}. $opt{charge}; }, #PRICE
+    ],
+    'basic' => [
+      sub { time2str('%d %b - %I:%M %p', shift->calldate_unix) },
+      'dst',
+      $duration_sub,
+      sub { my($cdr, %opt) = @_; $opt{money_char}. $opt{charge}; }, #PRICE
+    ],
+    'default' => [
+
+      #DATE
+      sub { time2str($date_format, 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
+
+      #DEST ("Number")
+      sub { my($cdr, %opt) = @_; $opt{pretty_dst} || $cdr->dst; },
+
+      #REGIONNAME ("Destination")
+      sub { my($cdr, %opt) = @_; $opt{dst_regionname}; },
+
+      #DURATION
+      $duration_sub,
+
+      #PRICE
+      sub { my($cdr, %opt) = @_; 
+        $opt{charge} = '0.00' unless defined $opt{charge};
+        $opt{money_char}.$opt{charge}; 
+      },
+
+    ],
+  );
+  $export_formats{'source_default'} = [ 'src', @{ $export_formats{'default'} }, ];
+  $export_formats{'accountcode_default'} =
+    [ @{ $export_formats{'default'} }[0,1],
+      'accountcode',
+      @{ $export_formats{'default'} }[2..5],
+    ];
+  my @default = @{ $export_formats{'default'} };
+  $export_formats{'description_default'} = 
+    [ 'src', @default[0..2], 
+      sub { my($cdr, %opt) = @_; $cdr->description },
+      @default[4,5] ];
+
+  return %export_formats;
 }
 
 }
 
-=item downstream_csv [ OPTION => VALUE, ... ]
+=item downstream_csv OPTION => VALUE ...
 
 
-=cut
+Options:
 
 
-my %export_names = (
-  'convergent'      => {},
-  'voxlinesystems'  => { 'name'           => 'VoxLineSystems',
-                         'invoice_header' =>
-                           "Date,Time,Name,Destination,Duration,Price",
-                       },
-  'voxlinesystems2' => { 'name'           => 'VoxLineSystems with source',
-                         'invoice_header' =>
-                           "Date,Time,Name,Destination,Called From,Duration,Price",
-                       },
-);
+format
 
 
-my %export_formats = (
-  'convergent' => [
-    'carriername', #CARRIER
-    sub { shift->_convergent_format('src') }, #SERVICE_NUMBER
-    sub { shift->_convergent_format('charged_party') }, #CHARGED_NUMBER
-    sub { time2str('%Y-%m-%d', shift->calldate_unix ) }, #DATE
-    sub { time2str('%T',       shift->calldate_unix ) }, #TIME
-    'billsec', #'duration', #DURATION
-    sub { shift->_convergent_format('dst') }, #NUMBER_DIALED
-    '', #XXX add (from prefixes in most recent email) #FROM_DESC
-    '', #XXX add (from prefixes in most recent email) #TO_DESC
-    'calltypename', #CLASS_CODE
-    'rated_price', #PRICE
-    sub { shift->rated_price ? 'Y' : 'N' }, #RATED
-    '', #OTHER_INFO
-  ],
-  'voxlinesystems' => [
-    sub { time2str('%D', shift->calldate_unix ) },   #DATE
-    sub { time2str('%T', shift->calldate_unix ) },   #TIME
-    'userfield',                                     #USER
-    'dst',                                           #NUMBER_DIALED
-    sub { sprintf('%.2fm', shift->billsec / 60 ) },  #DURATION
-    sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
-  ],
-  'voxlinesystems2' => [
-    sub { time2str('%D', shift->calldate_unix ) },   #DATE
-    sub { time2str('%T', 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
-  ],
-);
+charge
+
+seconds
+
+granularity
+
+=cut
 
 sub downstream_csv {
   my( $self, %opt ) = @_;
 
 
 sub downstream_csv {
   my( $self, %opt ) = @_;
 
-  my $format = $opt{'format'}; # 'convergent';
-  return "Unknown format $format" unless exists $export_formats{$format};
+  my $format = $opt{'format'};
+  my %formats = $self->export_formats;
+  return "Unknown format $format" unless exists $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 $@;
 
   eval "use Text::CSV_XS;";
   die $@ if $@;
@@ -451,9 +681,11 @@ sub downstream_csv {
 
   my @columns =
     map {
 
   my @columns =
     map {
-          ref($_) ? &{$_}($self) : $self->$_();
+          ref($_) ? &{$_}($self, %opt) : $self->$_();
         }
         }
-    @{ $export_formats{$format} };
+    @{ $formats{$format} };
+
+  return @columns if defined $opt{'keeparray'};
 
   my $status = $csv->combine(@columns);
   die "FS::CDR: error combining ". $csv->error_input(). "into downstream CSV"
 
   my $status = $csv->combine(@columns);
   die "FS::CDR: error combining ". $csv->error_input(). "into downstream CSV"
@@ -493,6 +725,50 @@ sub invoice_header {
   $export_names{$format}->{'invoice_header'};
 }
 
   $export_names{$format}->{'invoice_header'};
 }
 
+=item clear_status 
+
+Clears cdr and any associated cdr_termination statuses - used for 
+CDR reprocessing.
+
+=cut
+
+sub clear_status {
+  my $self = shift;
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  $self->freesidestatus('');
+  my $error = $self->replace;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  } 
+
+  my @cdr_termination = qsearch('cdr_termination', 
+                               { 'acctid' => $self->acctid } );
+  foreach my $cdr_termination ( @cdr_termination ) {
+      $cdr_termination->status('');
+      $error = $cdr_termination->replace;
+      if ( $error ) {
+       $dbh->rollback if $oldAutoCommit;
+       return $error;
+      } 
+  }
+  
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  '';
+}
+
 =item import_formats
 
 Returns an ordered list of key value pairs containing import format names
 =item import_formats
 
 Returns an ordered list of key value pairs containing import format names
@@ -500,313 +776,126 @@ as keys (for use with batch_import) and "pretty" format names as values.
 
 =cut
 
 
 =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 {
 sub import_formats {
-  (
-    'asterisk'       => 'Asterisk',
-    'taqua'          => 'Taqua',
-    'unitel'         => 'Unitel/RSLCOM',
-    'voxlinesystems' => 'VoxLineSystems',  #XXX? get the actual vendor name
-    'simple'         => 'Simple',
-  );
+  %import_formats;
 }
 
 }
 
-my($tmp_mday, $tmp_mon, $tmp_year);
+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;
 
 sub _cdr_date_parser_maker {
   my $field = shift;
+  my %options = @_;
+  my @fields = ref($field) ? @$field : ($field);
   return sub {
   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 $@;
+    my( $cdr, $datestring ) = @_;
+    my $unixdate = eval { _cdr_date_parse($datestring, %options) };
+    die "error parsing date for @fields from $datestring: $@\n" if $@;
+    $cdr->$_($unixdate) foreach @fields;
   };
 }
 
 sub _cdr_date_parse {
   my $date = shift;
   };
 }
 
 sub _cdr_date_parse {
   my $date = shift;
+  my %options = @_;
 
   return '' unless length($date); #that's okay, it becomes NULL
 
   return '' unless length($date); #that's okay, it becomes NULL
+  return '' if $date eq 'NA'; #sansay
 
 
-  #$date =~ /^\s*(\d{4})[\-\/]\(\d{1,2})[\-\/](\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})\s*$/
-  $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|$)/
-    or die "unparsable date: $date"; #maybe we shouldn't die...
-  my($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
-
-  return '' if $year == 1900 && $mon == 1 && $day == 1
-            && $hour == 0    && $min == 0 && $sec == 0;
+  if ( $date =~ /^([a-z]{3})\s+([a-z]{3})\s+(\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})\s+(\d{4})$/i && $7 > 1970 ) {
+    my $time = str2time($date);
+    return $time if $time > 100000; #just in case
+  }
 
 
-  timelocal($sec, $min, $hour, $day, $mon-1, $year);
-}
+  my($year, $mon, $day, $hour, $min, $sec);
 
 
-#taqua  #2007-10-31 08:57:24.113000000
-
-#http://www.the-asterisk-book.com/unstable/funktionen-cdr.html
-my %amaflags = (
-  DEFAULT       => 0,
-  OMIT          => 1, #asterisk 1.4+
-  IGNORE        => 1, #asterisk 1.2
-  BILLING       => 2, #asterisk 1.4+
-  BILL          => 2, #asterisk 1.2
-  DOCUMENTATION => 3,
-  #? '' => 0,
-);
+  #$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})\D+(\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|$)/ ) {
+    # 8/26/2010 12:20:01
+    # optionally without seconds
+    ($mon, $day, $year, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
+    $sec = 0 if !defined($sec);
+  } elsif ( $date  =~ /^\s*(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d+\.\d+)(\D|$)/ ) {
+    # broadsoft: 20081223201938.314
+    ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
+  } elsif ( $date  =~ /^\s*(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\d+(\D|$)/ ) {
+    # Taqua OM:  20050422203450943
+    ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
+  } elsif ( $date  =~ /^\s*(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/ ) {
+    # WIP: 20100329121420
+    ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
+  } elsif ( $date =~ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z$/) {
+    # Telos
+    ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
+    $options{gmt} = 1;
+  } else {
+     die "unparsable date: $date"; #maybe we shouldn't die...
+  }
 
 
-my %import_formats = (
-  'asterisk' => [
-    'accountcode',
-    'src',
-    'dst',
-    'dcontext',
-    'clid',
-    'channel',
-    'dstchannel',
-    'lastapp',
-    'lastdata',
-    _cdr_date_parser_maker('startdate'),
-    _cdr_date_parser_maker('answerdate'),
-    _cdr_date_parser_maker('enddate'),
-    'duration',
-    'billsec',
-    'disposition',
-    sub { my($cdr, $amaflags) = @_; $cdr->amaflags($amaflags{$amaflags}); },
-    'uniqueid',
-    'userfield',
-  ],
-  'taqua' => [ #some of these are kind arbitrary...
-
-    sub { my($cdr, $field) = @_; },       #XXX interesting RecordType
-             # easy to fix: Can't find cdr.cdrtypenum 1 in cdr_type.cdrtypenum
-
-    sub { my($cdr, $field) = @_; },             #all10#RecordVersion
-    sub { my($cdr, $field) = @_; },       #OrigShelfNumber
-    sub { my($cdr, $field) = @_; },       #OrigCardNumber
-    sub { my($cdr, $field) = @_; },       #OrigCircuit
-    sub { my($cdr, $field) = @_; },       #OrigCircuitType
-    'uniqueid',                           #SequenceNumber
-    'accountcode',                        #SessionNumber
-    'src',                                #CallingPartyNumber
-    'dst',                                #CalledPartyNumber
-    _cdr_date_parser_maker('startdate'),  #CallArrivalTime
-    _cdr_date_parser_maker('enddate'),    #CallCompletionTime
-
-    #Disposition
-    #sub { my($cdr, $d ) = @_; $cdr->disposition( $disposition{$d}): },
-    'disposition',
-                                          #  -1 => '',
-                                          #   0 => '',
-                                          # 100 => '',
-                                          # 101 => '',
-                                          # 102 => '',
-                                          # 103 => '',
-                                          # 104 => '',
-                                          # 105 => '',
-                                          # 201 => '',
-                                          # 203 => '',
-
-    _cdr_date_parser_maker('answerdate'), #DispositionTime
-    sub { my($cdr, $field) = @_; },       #TCAP
-    sub { my($cdr, $field) = @_; },       #OutboundCarrierConnectTime
-    sub { my($cdr, $field) = @_; },       #OutboundCarrierDisconnectTime
-
-    #TermTrunkGroup
-    #it appears channels are actually part of trunk groups, but this data
-    #is interesting and we need a source and destination place to put it
-    'dstchannel',                         #TermTrunkGroup
-
-
-    sub { my($cdr, $field) = @_; },       #TermShelfNumber
-    sub { my($cdr, $field) = @_; },       #TermCardNumber
-    sub { my($cdr, $field) = @_; },       #TermCircuit
-    sub { my($cdr, $field) = @_; },       #TermCircuitType
-    sub { my($cdr, $field) = @_; },       #OutboundCarrierId
-    'charged_party',                      #BillingNumber
-    sub { my($cdr, $field) = @_; },       #SubscriberNumber
-    'lastapp',                            #ServiceName
-    sub { my($cdr, $field) = @_; },       #some weirdness #ChargeTime
-    'lastdata',                           #ServiceInformation
-    sub { my($cdr, $field) = @_; },       #FacilityInfo
-    sub { my($cdr, $field) = @_; },             #all 1900-01-01 0#CallTraceTime
-    sub { my($cdr, $field) = @_; },             #all-1#UniqueIndicator
-    sub { my($cdr, $field) = @_; },             #all-1#PresentationIndicator
-    sub { my($cdr, $field) = @_; },             #empty#Pin
-    sub { my($cdr, $field) = @_; },       #CallType
-    sub { my($cdr, $field) = @_; },           #Balt/empty #OrigRateCenter
-    sub { my($cdr, $field) = @_; },           #Balt/empty #TermRateCenter
-
-    #OrigTrunkGroup
-    #it appears channels are actually part of trunk groups, but this data
-    #is interesting and we need a source and destination place to put it
-    'channel',                            #OrigTrunkGroup
-
-    'userfield',                                #empty#UserDefined
-    sub { my($cdr, $field) = @_; },             #empty#PseudoDestinationNumber
-    sub { my($cdr, $field) = @_; },             #all-1#PseudoCarrierCode
-    sub { my($cdr, $field) = @_; },             #empty#PseudoANI
-    sub { my($cdr, $field) = @_; },             #all-1#PseudoFacilityInfo
-    sub { my($cdr, $field) = @_; },       #OrigDialedDigits
-    sub { my($cdr, $field) = @_; },             #all-1#OrigOutboundCarrier
-    sub { my($cdr, $field) = @_; },       #IncomingCarrierID
-    'dcontext',                           #JurisdictionInfo
-    sub { my($cdr, $field) = @_; },       #OrigDestDigits
-    sub { my($cdr, $field) = @_; },       #huh?#InsertTime
-    sub { my($cdr, $field) = @_; },       #key
-    sub { my($cdr, $field) = @_; },             #empty#AMALineNumber
-    sub { my($cdr, $field) = @_; },             #empty#AMAslpID
-    sub { my($cdr, $field) = @_; },             #empty#AMADigitsDialedWC
-    sub { my($cdr, $field) = @_; },       #OpxOffHook
-    sub { my($cdr, $field) = @_; },       #OpxOnHook
-
-        #acctid - primary key
-  #AUTO #calldate - Call timestamp (SQL timestamp)
-#clid - Caller*ID with text
-        #XXX src - Caller*ID number / Source number
-        #XXX dst - Destination extension
-        #dcontext - Destination context
-        #channel - Channel used
-        #dstchannel - Destination channel if appropriate
-        #lastapp - Last application if appropriate
-        #lastdata - Last application data
-        #startdate - Start of call (UNIX-style integer timestamp)
-        #answerdate - Answer time of call (UNIX-style integer timestamp)
-        #enddate - End time of call (UNIX-style integer timestamp)
-  #HACK#duration - Total time in system, in seconds
-  #HACK#XXX billsec - Total time call is up, in seconds
-        #disposition - What happened to the call: ANSWERED, NO ANSWER, BUSY
-#INT amaflags - What flags to use: BILL, IGNORE etc, specified on a per channel basis like accountcode.
-        #accountcode - CDR account number to use: account
-
-        #uniqueid - Unique channel identifier (Unitel/RSLCOM Event ID)
-        #userfield - CDR user-defined field
-
-        #X cdrtypenum - CDR type - see FS::cdr_type (Usage = 1, S&E = 7, OC&C = 8)
-        #XXX charged_party - Service number to be billed
-#upstream_currency - Wholesale currency from upstream
-#X upstream_price - Wholesale price from upstream
-#upstream_rateplanid - Upstream rate plan ID
-#rated_price - Rated (or re-rated) price
-#distance - km (need units field?)
-#islocal - Local - 1, Non Local = 0
-#calltypenum - Type of call - see FS::cdr_calltype
-#X description - Description (cdr_type 7&8 only) (used for cust_bill_pkg.itemdesc)
-#quantity - Number of items (cdr_type 7&8 only)
-#carrierid - Upstream Carrier ID (see FS::cdr_carrier)
-#upstream_rateid - Upstream Rate ID
-
-        #svcnum - Link to customer service (see FS::cust_svc)
-        #freesidestatus - NULL, done (or something)
-
-  ],
-  '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',
-  ],
-  'voxlinesystems' => [ #XXX get the actual vendor name
-    'disposition',                        #Status
-    'startdate',                          #Start (what do you know, a timestamp!
-    sub { my($cdr, $field) = @_; },       #Start date
-    sub { my($cdr, $field) = @_; },       #Start time
-    'enddate',                            #End (also a timestamp!)
-    sub { my($cdr, $field) = @_; },       #End date
-    sub { my($cdr, $field) = @_; },       #End time
-    'accountcode',                        #Calling customer XXX map to agent_custid??
-    sub { my($cdr, $field) = @_; },       #Calling type
-    sub { shift->src('30000'); }, #XXX FAKE XXX 'src',                                #Calling number
-    'userfield',                          #Calling name #?
-    sub { my($cdr, $field) = @_; },       #Called type
-    'dst',                                #Called number
-    sub { my($cdr, $field) = @_; },       #Destination customer
-    sub { my($cdr, $field) = @_; },       #Destination type
-    sub { my($cdr, $field) = @_; },       #Destination Number
-    sub { my($cdr, $field) = @_; },       #Inbound calling type
-    sub { my($cdr, $field) = @_; },       #Inbound calling number
-    sub { my($cdr, $field) = @_; },       #Inbound called type
-    sub { my($cdr, $field) = @_; },       #Inbound called number
-    sub { my($cdr, $field) = @_; },       #Inbound destination type
-    sub { my($cdr, $field) = @_; },       #Inbound destination number
-    sub { my($cdr, $field) = @_; },       #Outbound calling type
-    sub { my($cdr, $field) = @_; },       #Outbound calling number
-    sub { my($cdr, $field) = @_; },       #Outbound called type
-    sub { my($cdr, $field) = @_; },       #Outbound called number
-    sub { my($cdr, $field) = @_; },       #Outbound destination type
-    sub { my($cdr, $field) = @_; },       #Outbound destination number
-    sub { my($cdr, $field) = @_; },       #Internal calling type
-    sub { my($cdr, $field) = @_; },       #Internal calling number
-    sub { my($cdr, $field) = @_; },       #Internal called type
-    sub { my($cdr, $field) = @_; },       #Internal called number
-    sub { my($cdr, $field) = @_; },       #Internal destination type
-    sub { my($cdr, $field) = @_; },       #Internal destination number
-    'duration',                           #Total seconds
-    sub { my($cdr, $field) = @_; },       #Ring seconds
-    'billsec',                            #Billable seconds
-    'upstream_price',                     #Cost
-    sub { my($cdr, $field) = @_; },       #Billing customer
-    sub { my($cdr, $field) = @_; },       #Billing customer name
-    sub { my($cdr, $field) = @_; },       #Billing type
-    sub { my($cdr, $field) = @_; },       #Billing reference
-  ],
-  'simple' => [
-
-    # Date
-    sub { my($cdr, $date) = @_;
-          $date =~ /^(\d{1,2})\/(\d{1,2})\/(\d\d(\d\d)?)$/
-            or die "unparsable date: $date"; #maybe we shouldn't die...
-          #$cdr->startdate( timelocal(0, 0, 0 ,$2, $1-1, $3) );
-          ($tmp_mday, $tmp_mon, $tmp_year) = ( $2, $1-1, $3 );
-        },
-
-    # Time
-    sub { my($cdr, $time) = @_;
-          #my($sec, $min, $hour, $mday, $mon, $year)= localtime($cdr->startdate);
-          $time =~ /^(\d{1,2}):(\d{1,2}):(\d{1,2})$/
-            or die "unparsable time: $time"; #maybe we shouldn't die...
-          #$cdr->startdate( timelocal($3, $2, $1 ,$mday, $mon, $year) );
-          $cdr->startdate(
-            timelocal($3, $2, $1 ,$tmp_mday, $tmp_mon, $tmp_year)
-          );
-        },
-
-    # Source_Number
-    'src',
-
-    # Terminating_Number
-    'dst',
-
-    # Duration
-    sub { my($cdr, $min) = @_;
-          my $sec = sprintf('%.0f', $min * 60 );
-          $cdr->billsec(  $sec );
-          $cdr->duration( $sec );
-        },
-
-  ],
-);
+  return '' if ( $year == 1900 || $year == 1970 ) && $mon == 1 && $day == 1
+            && $hour == 0 && $min == 0 && $sec == 0;
 
 
-my %import_header = (
-  'simple'         => 1,
-  'taqua'          => 1,
-  'voxlinesystems' => 2, #XXX vendor name
-);
+  if ($options{gmt}) {
+    timegm($sec, $min, $hour, $day, $mon-1, $year);
+  } else {
+    timelocal($sec, $min, $hour, $day, $mon-1, $year);
+  }
+}
 
 =item batch_import HASHREF
 
 
 =item batch_import HASHREF
 
@@ -814,112 +903,135 @@ Imports CDR records.  Available options are:
 
 =over 4
 
 
 =over 4
 
-=item filehandle
+=item file
+
+Filename
 
 =item format
 
 
 =item format
 
+=item params
+
+Hash reference of preset fields, typically cdrbatch
+
+=item empty_ok
+
+Set true to prevent throwing an error on empty imports
+
 =back
 
 =cut
 
 =back
 
 =cut
 
-sub batch_import {
-  my $param = shift;
+my %import_options = (
+  'table'         => 'cdr',
 
 
-  my $fh = $param->{filehandle};
-  my $format = $param->{format};
+  'batch_keycol'  => 'cdrbatchnum',
+  'batch_table'   => 'cdr_batch',
+  'batch_namecol' => 'cdrbatch',
 
 
-  return "Unknown format $format" unless exists $import_formats{$format};
+  'formats' => { map { $_ => $cdr_info{$_}->{'import_fields'}; }
+                     keys %cdr_info
+               },
 
 
-  eval "use Text::CSV_XS;";
-  die $@ if $@;
+                          #drop the || 'csv' to allow auto xls for csv types?
+  'format_types' => { map { $_ => lc($cdr_info{$_}->{'type'} || 'csv'); }
+                          keys %cdr_info
+                    },
 
 
-  my $csv = new Text::CSV_XS;
+  'format_headers' => { map { $_ => ( $cdr_info{$_}->{'header'} || 0 ); }
+                            keys %cdr_info
+                      },
 
 
-  my $imported = 0;
-  #my $columns;
+  'format_sep_chars' => { map { $_ => $cdr_info{$_}->{'sep_char'}; }
+                              keys %cdr_info
+                        },
 
 
-  local $SIG{HUP} = 'IGNORE';
-  local $SIG{INT} = 'IGNORE';
-  local $SIG{QUIT} = 'IGNORE';
-  local $SIG{TERM} = 'IGNORE';
-  local $SIG{TSTP} = 'IGNORE';
-  local $SIG{PIPE} = 'IGNORE';
+  'format_fixedlength_formats' =>
+    { map { $_ => $cdr_info{$_}->{'fixedlength_format'}; }
+          keys %cdr_info
+    },
 
 
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
+  'format_xml_formats' =>
+    { map { $_ => $cdr_info{$_}->{'xml_format'}; }
+          keys %cdr_info
+    },
 
 
-  my $header_lines =
-    exists($import_header{$format}) ? $import_header{$format} : 0;
+  'format_row_callbacks' => { map { $_ => $cdr_info{$_}->{'row_callback'}; }
+                                  keys %cdr_info
+                            },
+);
 
 
-  my $line;
-  while ( defined($line=<$fh>) ) {
+sub _import_options {
+  \%import_options;
+}
 
 
-    next if $header_lines-- > 0; #&& $line =~ /^[\w, "]+$/ 
+sub batch_import {
+  my $opt = shift;
 
 
-    $csv->parse($line) or do {
-      $dbh->rollback if $oldAutoCommit;
-      return "can't parse: ". $csv->error_input();
-    };
+  my $iopt = _import_options;
+  $opt->{$_} = $iopt->{$_} foreach keys %$iopt;
 
 
-    my @columns = $csv->fields();
-    #warn join('-',@columns);
+  if ( defined $opt->{'cdrtypenum'} ) {
+        $opt->{'preinsert_callback'} = sub {
+                my($record,$param) = (shift,shift);
+                $record->cdrtypenum($opt->{'cdrtypenum'});
+                '';
+        };
+  }
 
 
-    if ( $format eq 'simple' ) {
-      @columns = map { s/^ +//; $_; } @columns;
-    }
+  FS::Record::batch_import( $opt );
 
 
-    my @later = ();
-    my %cdr =
-      map {
+}
 
 
-        my $field_or_sub = $_;
-        if ( ref($field_or_sub) ) {
-          push @later, $field_or_sub, shift(@columns);
-          ();
-        } else {
-          ( $field_or_sub => shift @columns );
-        }
+=item process_batch_import
 
 
-      }
-      @{ $import_formats{$format} }
-    ;
+=cut
 
 
-    my $cdr = new FS::cdr ( \%cdr );
+sub process_batch_import {
+  my $job = shift;
 
 
-    while ( scalar(@later) ) {
-      my $sub = shift @later;
-      my $data = shift @later;
-      &{$sub}($cdr, $data);  # $cdr->&{$sub}($data); 
-    }
+  my $opt = _import_options;
+#  $opt->{'params'} = [ 'format', 'cdrbatch' ];
 
 
-    if ( $format eq '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 );
-      } 
-    }
+  FS::Record::process_batch_import( $job, $opt, @_ );
 
 
-    my $error = $cdr->insert;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
+}
+#  if ( $format eq 'simple' ) { #should be a callback or opt in FS::cdr::simple
+#    @columns = map { s/^ +//; $_; } @columns;
+#  }
 
 
-      #or just skip?
-      #next;
+# _ upgrade_data
+#
+# Used by FS::Upgrade to migrate to a new database.
+
+sub _upgrade_data {
+  my ($class, %opts) = @_;
+
+  warn "$me upgrading $class\n" if $DEBUG;
+
+  my $sth = dbh->prepare(
+    'SELECT DISTINCT(cdrbatch) FROM cdr WHERE cdrbatch IS NOT NULL'
+  ) or die dbh->errstr;
+
+  $sth->execute or die $sth->errstr;
+
+  my %cdrbatchnum = ();
+  while (my $row = $sth->fetchrow_arrayref) {
+
+    my $cdr_batch = qsearchs( 'cdr_batch', { 'cdrbatch' => $row->[0] } );
+    unless ( $cdr_batch ) {
+      $cdr_batch = new FS::cdr_batch { 'cdrbatch' => $row->[0] };
+      my $error = $cdr_batch->insert;
+      die $error if $error;
     }
 
     }
 
-    $imported++;
+    $cdrbatchnum{$row->[0]} = $cdr_batch->cdrbatchnum;
   }
 
   }
 
-  $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;
+  $sth = dbh->prepare('UPDATE cdr SET cdrbatch = NULL, cdrbatchnum = ? WHERE cdrbatch IS NOT NULL AND cdrbatch = ?') or die dbh->errstr;
 
 
-  '';
+  foreach my $cdrbatch (keys %cdrbatchnum) {
+    $sth->execute($cdrbatchnum{$cdrbatch}, $cdrbatch) or die $sth->errstr;
+  }
 
 }
 
 
 }