4 use vars qw( @ISA @EXPORT_OK $DEBUG );
10 use FS::UID qw( dbh );
11 use FS::Record qw( qsearch qsearchs );
15 use FS::cdr_upstream_rate;
17 @ISA = qw(FS::Record);
18 @EXPORT_OK = qw( _cdr_date_parser_maker _cdr_min_parser_maker );
24 FS::cdr - Object methods for cdr records
30 $record = new FS::cdr \%hash;
31 $record = new FS::cdr { 'column' => 'value' };
33 $error = $record->insert;
35 $error = $new_record->replace($old_record);
37 $error = $record->delete;
39 $error = $record->check;
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:
49 =item acctid - primary key
51 =item calldate - Call timestamp (SQL timestamp)
53 =item clid - Caller*ID with text
55 =item src - Caller*ID number / Source number
57 =item dst - Destination extension
59 =item dcontext - Destination context
61 =item channel - Channel used
63 =item dstchannel - Destination channel if appropriate
65 =item lastapp - Last application if appropriate
67 =item lastdata - Last application data
69 =item startdate - Start of call (UNIX-style integer timestamp)
71 =item answerdate - Answer time of call (UNIX-style integer timestamp)
73 =item enddate - End time of call (UNIX-style integer timestamp)
75 =item duration - Total time in system, in seconds
77 =item billsec - Total time call is up, in seconds
79 =item disposition - What happened to the call: ANSWERED, NO ANSWER, BUSY
81 =item amaflags - What flags to use: BILL, IGNORE etc, specified on a per channel basis like accountcode.
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.
92 =item accountcode - CDR account number to use: account
94 =item uniqueid - Unique channel identifier (Unitel/RSLCOM Event ID)
96 =item userfield - CDR user-defined field
98 =item cdr_type - CDR type - see L<FS::cdr_type> (Usage = 1, S&E = 7, OC&C = 8)
100 =item charged_party - Service number to be billed
102 =item upstream_currency - Wholesale currency from upstream
104 =item upstream_price - Wholesale price from upstream
106 =item upstream_rateplanid - Upstream rate plan ID
108 =item rated_price - Rated (or re-rated) price
110 =item distance - km (need units field?)
112 =item islocal - Local - 1, Non Local = 0
114 =item calltypenum - Type of call - see L<FS::cdr_calltype>
116 =item description - Description (cdr_type 7&8 only) (used for cust_bill_pkg.itemdesc)
118 =item quantity - Number of items (cdr_type 7&8 only)
120 =item carrierid - Upstream Carrier ID (see L<FS::cdr_carrier>)
124 #Telstra =1, Optus = 2, RSL COM = 3
126 =item upstream_rateid - Upstream Rate ID
128 =item svcnum - Link to customer service (see L<FS::cust_svc>)
130 =item freesidestatus - NULL, done (or something)
142 Creates a new CDR. To add the CDR to the database, see L<"insert">.
144 Note that this stores the hash reference, not a distinct copy of the hash it
145 points to. You can ask the object for a copy with the I<hash> method.
149 # the new method can be inherited from FS::Record, if a table method is defined
155 Adds this record to the database. If there is an error, returns the error,
156 otherwise returns false.
160 # the insert method can be inherited from FS::Record
164 Delete this record from the database.
168 # the delete method can be inherited from FS::Record
170 =item replace OLD_RECORD
172 Replaces the OLD_RECORD with this one in the database. If there is an error,
173 returns the error, otherwise returns false.
177 # the replace method can be inherited from FS::Record
181 Checks all fields to make sure this is a valid CDR. If there is
182 an error, returns the error, otherwise returns false. Called by the insert
185 Note: Unlike most types of records, we don't want to "reject" a CDR and we want
186 to process them as quickly as possible, so we allow the database to check most
194 # we don't want to "reject" a CDR like other sorts of input...
196 # $self->ut_numbern('acctid')
197 ## || $self->ut_('calldate')
198 # || $self->ut_text('clid')
199 # || $self->ut_text('src')
200 # || $self->ut_text('dst')
201 # || $self->ut_text('dcontext')
202 # || $self->ut_text('channel')
203 # || $self->ut_text('dstchannel')
204 # || $self->ut_text('lastapp')
205 # || $self->ut_text('lastdata')
206 # || $self->ut_numbern('startdate')
207 # || $self->ut_numbern('answerdate')
208 # || $self->ut_numbern('enddate')
209 # || $self->ut_number('duration')
210 # || $self->ut_number('billsec')
211 # || $self->ut_text('disposition')
212 # || $self->ut_number('amaflags')
213 # || $self->ut_text('accountcode')
214 # || $self->ut_text('uniqueid')
215 # || $self->ut_text('userfield')
216 # || $self->ut_numbern('cdrtypenum')
217 # || $self->ut_textn('charged_party')
218 ## || $self->ut_n('upstream_currency')
219 ## || $self->ut_n('upstream_price')
220 # || $self->ut_numbern('upstream_rateplanid')
221 ## || $self->ut_n('distance')
222 # || $self->ut_numbern('islocal')
223 # || $self->ut_numbern('calltypenum')
224 # || $self->ut_textn('description')
225 # || $self->ut_numbern('quantity')
226 # || $self->ut_numbern('carrierid')
227 # || $self->ut_numbern('upstream_rateid')
228 # || $self->ut_numbern('svcnum')
229 # || $self->ut_textn('freesidestatus')
231 # return $error if $error;
233 $self->calldate( $self->startdate_sql )
234 if !$self->calldate && $self->startdate;
236 my $conf = new FS::Conf;
238 unless ( $self->charged_party ) {
240 if ( $conf->exists('cdr-charged_party-accountcode') && $self->accountcode ){
242 $self->charged_party( $self->accountcode );
246 if ( $self->dst =~ /^(\+?1)?8[02-8]{2}/ ) {
247 $self->charged_party($self->dst);
249 $self->charged_party($self->src);
256 #check the foreign keys even?
257 #do we want to outright *reject* the CDR?
259 $self->ut_numbern('acctid')
261 #add a config option to turn these back on if someone needs 'em
263 # #Usage = 1, S&E = 7, OC&C = 8
264 # || $self->ut_foreign_keyn('cdrtypenum', 'cdr_type', 'cdrtypenum' )
266 # #the big list in appendix 2
267 # || $self->ut_foreign_keyn('calltypenum', 'cdr_calltype', 'calltypenum' )
269 # # Telstra =1, Optus = 2, RSL COM = 3
270 # || $self->ut_foreign_keyn('carrierid', 'cdr_carrier', 'carrierid' )
272 return $error if $error;
277 =item set_status_and_rated_price STATUS [ RATED_PRICE ]
279 Sets the status to the provided string. If there is an error, returns the
280 error, otherwise returns false.
284 sub set_status_and_rated_price {
285 my($self, $status, $rated_price) = @_;
286 $self->freesidestatus($status);
287 $self->rated_price($rated_price);
293 Parses the calldate in SQL string format and returns a UNIX timestamp.
298 str2time(shift->calldate);
303 Parses the startdate in UNIX timestamp format and returns a string in SQL
309 my($sec,$min,$hour,$mday,$mon,$year) = localtime(shift->startdate);
312 "$year-$mon-$mday $hour:$min:$sec";
317 Returns the FS::cdr_carrier object associated with this CDR, or false if no
318 carrierid is defined.
322 my %carrier_cache = ();
326 return '' unless $self->carrierid;
327 $carrier_cache{$self->carrierid} ||=
328 qsearchs('cdr_carrier', { 'carrierid' => $self->carrierid } );
333 Returns the carrier name (see L<FS::cdr_carrier>), or the empty string if
334 no FS::cdr_carrier object is assocated with this CDR.
340 my $cdr_carrier = $self->cdr_carrier;
341 $cdr_carrier ? $cdr_carrier->carriername : '';
346 Returns the FS::cdr_calltype object associated with this CDR, or false if no
347 calltypenum is defined.
351 my %calltype_cache = ();
355 return '' unless $self->calltypenum;
356 $calltype_cache{$self->calltypenum} ||=
357 qsearchs('cdr_calltype', { 'calltypenum' => $self->calltypenum } );
362 Returns the call type name (see L<FS::cdr_calltype>), or the empty string if
363 no FS::cdr_calltype object is assocated with this CDR.
369 my $cdr_calltype = $self->cdr_calltype;
370 $cdr_calltype ? $cdr_calltype->calltypename : '';
373 =item cdr_upstream_rate
375 Returns the upstream rate mapping (see L<FS::cdr_upstream_rate>), or the empty
376 string if no FS::cdr_upstream_rate object is associated with this CDR.
380 sub cdr_upstream_rate {
382 return '' unless $self->upstream_rateid;
383 qsearchs('cdr_upstream_rate', { 'upstream_rateid' => $self->upstream_rateid })
387 =item _convergent_format COLUMN [ COUNTRYCODE ]
389 Returns the number in COLUMN formatted as follows:
391 If the country code does not match COUNTRYCODE (default "61"), it is returned
394 If the country code does match COUNTRYCODE (default "61"), it is removed. In
395 addiiton, "0" is prepended unless the number starts with 13, 18 or 19. (???)
399 sub _convergent_format {
400 my( $self, $field ) = ( shift, shift );
401 my $countrycode = scalar(@_) ? shift : '61'; #+61 = australia
402 #my $number = $self->$field();
403 my $number = $self->get($field);
404 #if ( $number =~ s/^(\+|011)$countrycode// ) {
405 if ( $number =~ s/^\+$countrycode// ) {
407 unless $number =~ /^1[389]/; #???
412 =item downstream_csv [ OPTION => VALUE, ... ]
418 'simple' => { 'name' => 'Simple',
420 "Date,Time,Name,Destination,Duration,Price",
422 'simple2' => { 'name' => 'Simple with source',
424 #"Date,Time,Name,Called From,Destination,Duration,Price",
425 "Date,Time,Called From,Destination,Duration,Price",
429 my %export_formats = (
431 'carriername', #CARRIER
432 sub { shift->_convergent_format('src') }, #SERVICE_NUMBER
433 sub { shift->_convergent_format('charged_party') }, #CHARGED_NUMBER
434 sub { time2str('%Y-%m-%d', shift->calldate_unix ) }, #DATE
435 sub { time2str('%T', shift->calldate_unix ) }, #TIME
436 'billsec', #'duration', #DURATION
437 sub { shift->_convergent_format('dst') }, #NUMBER_DIALED
438 '', #XXX add (from prefixes in most recent email) #FROM_DESC
439 '', #XXX add (from prefixes in most recent email) #TO_DESC
440 'calltypename', #CLASS_CODE
441 'rated_price', #PRICE
442 sub { shift->rated_price ? 'Y' : 'N' }, #RATED
446 sub { time2str('%D', shift->calldate_unix ) }, #DATE
447 sub { time2str('%r', shift->calldate_unix ) }, #TIME
449 'dst', #NUMBER_DIALED
450 sub { sprintf('%.2fm', shift->billsec / 60 ) }, #DURATION
451 sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
454 sub { time2str('%D', shift->calldate_unix ) }, #DATE
455 sub { time2str('%r', shift->calldate_unix ) }, #TIME
457 'dst', #NUMBER_DIALED
459 sub { sprintf('%.2fm', shift->billsec / 60 ) }, #DURATION
460 sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
465 my( $self, %opt ) = @_;
467 my $format = $opt{'format'}; # 'convergent';
468 return "Unknown format $format" unless exists $export_formats{$format};
470 eval "use Text::CSV_XS;";
472 my $csv = new Text::CSV_XS;
476 ref($_) ? &{$_}($self) : $self->$_();
478 @{ $export_formats{$format} };
480 my $status = $csv->combine(@columns);
481 die "FS::CDR: error combining ". $csv->error_input(). "into downstream CSV"
494 =item invoice_formats
496 Returns an ordered list of key value pairs containing invoice format names
497 as keys (for use with part_pkg::voip_cdr) and "pretty" format names as values.
501 sub invoice_formats {
502 map { ($_ => $export_names{$_}->{'name'}) }
503 grep { $export_names{$_}->{'invoice_header'} }
507 =item invoice_header FORMAT
509 Returns a scalar containing the CSV column header for invoice format FORMAT.
515 $export_names{$format}->{'invoice_header'};
520 Returns an ordered list of key value pairs containing import format names
521 as keys (for use with batch_import) and "pretty" format names as values.
525 #false laziness w/part_pkg & part_export
528 foreach my $INC ( @INC ) {
529 warn "globbing $INC/FS/cdr/*.pm\n" if $DEBUG;
530 foreach my $file ( glob("$INC/FS/cdr/*.pm") ) {
531 warn "attempting to load CDR format info from $file\n" if $DEBUG;
532 $file =~ /\/(\w+)\.pm$/ or do {
533 warn "unrecognized file in $INC/FS/cdr/: $file\n";
537 my $info = eval "use FS::cdr::$mod; ".
538 "\\%FS::cdr::$mod\::info;";
540 die "error using FS::cdr::$mod (skipping): $@\n" if $@;
543 unless ( keys %$info ) {
544 warn "no %info hash found in FS::cdr::$mod, skipping\n";
547 warn "got CDR format info from FS::cdr::$mod: $info\n" if $DEBUG;
548 if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
549 warn "skipping disabled CDR format FS::cdr::$mod" if $DEBUG;
552 $cdr_info{$mod} = $info;
556 tie my %import_formats, 'Tie::IxHash',
557 map { $_ => $cdr_info{$_}->{'name'} }
558 sort { $cdr_info{$a}->{'weight'} <=> $cdr_info{$b}->{'weight'} }
559 grep { exists($cdr_info{$_}->{'import_fields'}) }
566 sub _cdr_min_parser_maker {
568 my @fields = ref($field) ? @$field : ($field);
569 @fields = qw( billsec duration ) unless scalar(@fields) && $fields[0];
571 my( $cdr, $min ) = @_;
572 my $sec = eval { _cdr_min_parse($min) };
573 die "error parsing seconds for @fields from $min minutes: $@\n" if $@;
574 $cdr->$_($sec) foreach @fields;
580 sprintf('%.0f', $min * 60 );
583 sub _cdr_date_parser_maker {
586 my( $cdr, $date ) = @_;
587 #$cdr->$field( _cdr_date_parse($date) );
588 eval { $cdr->$field( _cdr_date_parse($date) ); };
589 die "error parsing date for $field from $date: $@\n" if $@;
593 sub _cdr_date_parse {
596 return '' unless length($date); #that's okay, it becomes NULL
598 my($year, $mon, $day, $hour, $min, $sec);
600 #$date =~ /^\s*(\d{4})[\-\/]\(\d{1,2})[\-\/](\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})\s*$/
601 #taqua #2007-10-31 08:57:24.113000000
603 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|$)/ ) {
604 ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
605 } 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|$)/ ) {
606 ($mon, $day, $year, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
608 die "unparsable date: $date"; #maybe we shouldn't die...
611 return '' if $year == 1900 && $mon == 1 && $day == 1
612 && $hour == 0 && $min == 0 && $sec == 0;
614 timelocal($sec, $min, $hour, $day, $mon-1, $year);
617 =item batch_import HASHREF
619 Imports CDR records. Available options are:
634 my $fh = $param->{filehandle};
635 my $format = $param->{format};
636 my $cdrbatch = $param->{cdrbatch};
638 return "Unknown format $format"
639 unless exists( $cdr_info{$format} )
640 && exists( $cdr_info{$format}->{'import_fields'} );
642 my $info = $cdr_info{$format};
644 my $type = exists($info->{'type'}) ? lc($info->{'type'}) : 'csv';
647 if ( $type eq 'csv' ) {
648 eval "use Text::CSV_XS;";
651 foreach ( grep exists($info->{$_}), qw( sep_char ) ) {
652 $attr{$_} = $info->{$_};
654 $parser = new Text::CSV_XS \%attr;
655 } elsif ( $type eq 'fixedlength' ) {
656 eval "use Parse::FixedLength;";
658 $parser = new Parse::FixedLength $info->{'fixedlength_format'};
660 die "Unknown CDR format type $type for format $format\n";
666 local $SIG{HUP} = 'IGNORE';
667 local $SIG{INT} = 'IGNORE';
668 local $SIG{QUIT} = 'IGNORE';
669 local $SIG{TERM} = 'IGNORE';
670 local $SIG{TSTP} = 'IGNORE';
671 local $SIG{PIPE} = 'IGNORE';
673 my $oldAutoCommit = $FS::UID::AutoCommit;
674 local $FS::UID::AutoCommit = 0;
677 my $header_lines = exists($info->{'header'}) ? $info->{'header'} : 0;
680 while ( defined($line=<$fh>) ) {
682 next if $header_lines-- > 0; #&& $line =~ /^[\w, "]+$/
685 if ( $type eq 'csv' ) {
687 $parser->parse($line) or do {
688 $dbh->rollback if $oldAutoCommit;
689 return "can't parse: ". $parser->error_input();
692 @columns = $parser->fields();
694 } elsif ( $type eq 'fixedlength' ) {
696 @columns = $parser->parse($line);
699 die "Unknown CDR format type $type for format $format\n";
702 #warn join('-',@columns);
704 if ( $format eq 'simple' ) { #should be a callback or opt in FS::cdr::simple
705 @columns = map { s/^ +//; $_; } @columns;
712 my $field_or_sub = $_;
713 if ( ref($field_or_sub) ) {
714 push @later, $field_or_sub, shift(@columns);
717 ( $field_or_sub => shift @columns );
721 @{ $info->{'import_fields'} }
724 $cdr{cdrbatch} = $cdrbatch;
726 my $cdr = new FS::cdr ( \%cdr );
728 while ( scalar(@later) ) {
729 my $sub = shift @later;
730 my $data = shift @later;
731 &{$sub}($cdr, $data); # $cdr->&{$sub}($data);
734 if ( $format eq 'taqua' ) { #should be a callback or opt in FS::cdr::taqua
735 if ( $cdr->enddate && $cdr->startdate ) { #a bit more?
736 $cdr->duration( $cdr->enddate - $cdr->startdate );
738 if ( $cdr->enddate && $cdr->answerdate ) { #a bit more?
739 $cdr->billsec( $cdr->enddate - $cdr->answerdate );
743 my $error = $cdr->insert;
745 $dbh->rollback if $oldAutoCommit;
755 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
757 #might want to disable this if we skip records for any reason...
758 return "Empty file!" unless $imported || $param->{empty_ok};
770 L<FS::Record>, schema.html from the base documentation.