indosoft CDR format, RT#4425
[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::Conf;
12 use FS::Record qw( qsearch qsearchs );
13 use FS::cdr_type;
14 use FS::cdr_calltype;
15 use FS::cdr_carrier;
16 use FS::cdr_upstream_rate;
17
18 @ISA = qw(FS::Record);
19 @EXPORT_OK = qw( _cdr_date_parser_maker _cdr_min_parser_maker );
20
21 $DEBUG = 0;
22
23 =head1 NAME
24
25 FS::cdr - Object methods for cdr records
26
27 =head1 SYNOPSIS
28
29   use FS::cdr;
30
31   $record = new FS::cdr \%hash;
32   $record = new FS::cdr { 'column' => 'value' };
33
34   $error = $record->insert;
35
36   $error = $new_record->replace($old_record);
37
38   $error = $record->delete;
39
40   $error = $record->check;
41
42 =head1 DESCRIPTION
43
44 An FS::cdr object represents an Call Data Record, typically from a telephony
45 system or provider of some sort.  FS::cdr inherits from FS::Record.  The
46 following fields are currently supported:
47
48 =over 4
49
50 =item acctid - primary key
51
52 =item calldate - Call timestamp (SQL timestamp)
53
54 =item clid - Caller*ID with text
55
56 =item src - Caller*ID number / Source number
57
58 =item dst - Destination extension
59
60 =item dcontext - Destination context
61
62 =item channel - Channel used
63
64 =item dstchannel - Destination channel if appropriate
65
66 =item lastapp - Last application if appropriate
67
68 =item lastdata - Last application data
69
70 =item startdate - Start of call (UNIX-style integer timestamp)
71
72 =item answerdate - Answer time of call (UNIX-style integer timestamp)
73
74 =item enddate - End time of call (UNIX-style integer timestamp)
75
76 =item duration - Total time in system, in seconds
77
78 =item billsec - Total time call is up, in seconds
79
80 =item disposition - What happened to the call: ANSWERED, NO ANSWER, BUSY 
81
82 =item amaflags - What flags to use: BILL, IGNORE etc, specified on a per channel basis like accountcode. 
83
84 =cut
85
86   #ignore the "omit" and "documentation" AMAs??
87   #AMA = Automated Message Accounting. 
88   #default: Sets the system default. 
89   #omit: Do not record calls. 
90   #billing: Mark the entry for billing 
91   #documentation: Mark the entry for documentation.
92
93 =item accountcode - CDR account number to use: account
94
95 =item uniqueid - Unique channel identifier (Unitel/RSLCOM Event ID)
96
97 =item userfield - CDR user-defined field
98
99 =item cdr_type - CDR type - see L<FS::cdr_type> (Usage = 1, S&E = 7, OC&C = 8)
100
101 =item charged_party - Service number to be billed
102
103 =item upstream_currency - Wholesale currency from upstream
104
105 =item upstream_price - Wholesale price from upstream
106
107 =item upstream_rateplanid - Upstream rate plan ID
108
109 =item rated_price - Rated (or re-rated) price
110
111 =item distance - km (need units field?)
112
113 =item islocal - Local - 1, Non Local = 0
114
115 =item calltypenum - Type of call - see L<FS::cdr_calltype>
116
117 =item description - Description (cdr_type 7&8 only) (used for cust_bill_pkg.itemdesc)
118
119 =item quantity - Number of items (cdr_type 7&8 only)
120
121 =item carrierid - Upstream Carrier ID (see L<FS::cdr_carrier>) 
122
123 =cut
124
125 #Telstra =1, Optus = 2, RSL COM = 3
126
127 =item upstream_rateid - Upstream Rate ID
128
129 =item svcnum - Link to customer service (see L<FS::cust_svc>)
130
131 =item freesidestatus - NULL, done (or something)
132
133 =item cdrbatch
134
135 =back
136
137 =head1 METHODS
138
139 =over 4
140
141 =item new HASHREF
142
143 Creates a new CDR.  To add the CDR to the database, see L<"insert">.
144
145 Note that this stores the hash reference, not a distinct copy of the hash it
146 points to.  You can ask the object for a copy with the I<hash> method.
147
148 =cut
149
150 # the new method can be inherited from FS::Record, if a table method is defined
151
152 sub table { 'cdr'; }
153
154 =item insert
155
156 Adds this record to the database.  If there is an error, returns the error,
157 otherwise returns false.
158
159 =cut
160
161 # the insert method can be inherited from FS::Record
162
163 =item delete
164
165 Delete this record from the database.
166
167 =cut
168
169 # the delete method can be inherited from FS::Record
170
171 =item replace OLD_RECORD
172
173 Replaces the OLD_RECORD with this one in the database.  If there is an error,
174 returns the error, otherwise returns false.
175
176 =cut
177
178 # the replace method can be inherited from FS::Record
179
180 =item check
181
182 Checks all fields to make sure this is a valid CDR.  If there is
183 an error, returns the error, otherwise returns false.  Called by the insert
184 and replace methods.
185
186 Note: Unlike most types of records, we don't want to "reject" a CDR and we want
187 to process them as quickly as possible, so we allow the database to check most
188 of the data.
189
190 =cut
191
192 sub check {
193   my $self = shift;
194
195 # we don't want to "reject" a CDR like other sorts of input...
196 #  my $error = 
197 #    $self->ut_numbern('acctid')
198 ##    || $self->ut_('calldate')
199 #    || $self->ut_text('clid')
200 #    || $self->ut_text('src')
201 #    || $self->ut_text('dst')
202 #    || $self->ut_text('dcontext')
203 #    || $self->ut_text('channel')
204 #    || $self->ut_text('dstchannel')
205 #    || $self->ut_text('lastapp')
206 #    || $self->ut_text('lastdata')
207 #    || $self->ut_numbern('startdate')
208 #    || $self->ut_numbern('answerdate')
209 #    || $self->ut_numbern('enddate')
210 #    || $self->ut_number('duration')
211 #    || $self->ut_number('billsec')
212 #    || $self->ut_text('disposition')
213 #    || $self->ut_number('amaflags')
214 #    || $self->ut_text('accountcode')
215 #    || $self->ut_text('uniqueid')
216 #    || $self->ut_text('userfield')
217 #    || $self->ut_numbern('cdrtypenum')
218 #    || $self->ut_textn('charged_party')
219 ##    || $self->ut_n('upstream_currency')
220 ##    || $self->ut_n('upstream_price')
221 #    || $self->ut_numbern('upstream_rateplanid')
222 ##    || $self->ut_n('distance')
223 #    || $self->ut_numbern('islocal')
224 #    || $self->ut_numbern('calltypenum')
225 #    || $self->ut_textn('description')
226 #    || $self->ut_numbern('quantity')
227 #    || $self->ut_numbern('carrierid')
228 #    || $self->ut_numbern('upstream_rateid')
229 #    || $self->ut_numbern('svcnum')
230 #    || $self->ut_textn('freesidestatus')
231 #  ;
232 #  return $error if $error;
233
234   $self->calldate( $self->startdate_sql )
235     if !$self->calldate && $self->startdate;
236
237   #was just for $format eq 'taqua' but can't see the harm... add something to
238   #disable if it becomes a problem
239   if ( $self->duration eq '' && $self->enddate && $self->startdate ) {
240     $self->duration( $self->enddate - $self->startdate  );
241   }
242   if ( $self->billsec eq '' && $self->enddate && $self->answerdate ) {
243     $self->billsec(  $self->enddate - $self->answerdate );
244   } 
245
246   my $conf = new FS::Conf;
247
248   unless ( $self->charged_party ) {
249
250     if ( $conf->exists('cdr-charged_party-accountcode') && $self->accountcode ){
251
252       $self->charged_party( $self->accountcode );
253
254     } else {
255
256       if ( $self->dst =~ /^(\+?1)?8[02-8]{2}/ ) {
257         $self->charged_party($self->dst);
258       } else {
259         $self->charged_party($self->src);
260       }
261
262     }
263
264   }
265
266   #check the foreign keys even?
267   #do we want to outright *reject* the CDR?
268   my $error =
269        $self->ut_numbern('acctid')
270
271   #add a config option to turn these back on if someone needs 'em
272   #
273   #  #Usage = 1, S&E = 7, OC&C = 8
274   #  || $self->ut_foreign_keyn('cdrtypenum',  'cdr_type',     'cdrtypenum' )
275   #
276   #  #the big list in appendix 2
277   #  || $self->ut_foreign_keyn('calltypenum', 'cdr_calltype', 'calltypenum' )
278   #
279   #  # Telstra =1, Optus = 2, RSL COM = 3
280   #  || $self->ut_foreign_keyn('carrierid', 'cdr_carrier', 'carrierid' )
281   ;
282   return $error if $error;
283
284   $self->SUPER::check;
285 }
286
287 =item set_status_and_rated_price STATUS [ RATED_PRICE ]
288
289 Sets the status to the provided string.  If there is an error, returns the
290 error, otherwise returns false.
291
292 =cut
293
294 sub set_status_and_rated_price {
295   my($self, $status, $rated_price) = @_;
296   $self->freesidestatus($status);
297   $self->rated_price($rated_price);
298   $self->replace();
299 }
300
301 =item calldate_unix 
302
303 Parses the calldate in SQL string format and returns a UNIX timestamp.
304
305 =cut
306
307 sub calldate_unix {
308   str2time(shift->calldate);
309 }
310
311 =item startdate_sql
312
313 Parses the startdate in UNIX timestamp format and returns a string in SQL
314 format.
315
316 =cut
317
318 sub startdate_sql {
319   my($sec,$min,$hour,$mday,$mon,$year) = localtime(shift->startdate);
320   $mon++;
321   $year += 1900;
322   "$year-$mon-$mday $hour:$min:$sec";
323 }
324
325 =item cdr_carrier
326
327 Returns the FS::cdr_carrier object associated with this CDR, or false if no
328 carrierid is defined.
329
330 =cut
331
332 my %carrier_cache = ();
333
334 sub cdr_carrier {
335   my $self = shift;
336   return '' unless $self->carrierid;
337   $carrier_cache{$self->carrierid} ||=
338     qsearchs('cdr_carrier', { 'carrierid' => $self->carrierid } );
339 }
340
341 =item carriername 
342
343 Returns the carrier name (see L<FS::cdr_carrier>), or the empty string if
344 no FS::cdr_carrier object is assocated with this CDR.
345
346 =cut
347
348 sub carriername {
349   my $self = shift;
350   my $cdr_carrier = $self->cdr_carrier;
351   $cdr_carrier ? $cdr_carrier->carriername : '';
352 }
353
354 =item cdr_calltype
355
356 Returns the FS::cdr_calltype object associated with this CDR, or false if no
357 calltypenum is defined.
358
359 =cut
360
361 my %calltype_cache = ();
362
363 sub cdr_calltype {
364   my $self = shift;
365   return '' unless $self->calltypenum;
366   $calltype_cache{$self->calltypenum} ||=
367     qsearchs('cdr_calltype', { 'calltypenum' => $self->calltypenum } );
368 }
369
370 =item calltypename 
371
372 Returns the call type name (see L<FS::cdr_calltype>), or the empty string if
373 no FS::cdr_calltype object is assocated with this CDR.
374
375 =cut
376
377 sub calltypename {
378   my $self = shift;
379   my $cdr_calltype = $self->cdr_calltype;
380   $cdr_calltype ? $cdr_calltype->calltypename : '';
381 }
382
383 =item cdr_upstream_rate
384
385 Returns the upstream rate mapping (see L<FS::cdr_upstream_rate>), or the empty
386 string if no FS::cdr_upstream_rate object is associated with this CDR.
387
388 =cut
389
390 sub cdr_upstream_rate {
391   my $self = shift;
392   return '' unless $self->upstream_rateid;
393   qsearchs('cdr_upstream_rate', { 'upstream_rateid' => $self->upstream_rateid })
394     or '';
395 }
396
397 =item _convergent_format COLUMN [ COUNTRYCODE ]
398
399 Returns the number in COLUMN formatted as follows:
400
401 If the country code does not match COUNTRYCODE (default "61"), it is returned
402 unchanged.
403
404 If the country code does match COUNTRYCODE (default "61"), it is removed.  In
405 addiiton, "0" is prepended unless the number starts with 13, 18 or 19. (???)
406
407 =cut
408
409 sub _convergent_format {
410   my( $self, $field ) = ( shift, shift );
411   my $countrycode = scalar(@_) ? shift : '61'; #+61 = australia
412   #my $number = $self->$field();
413   my $number = $self->get($field);
414   #if ( $number =~ s/^(\+|011)$countrycode// ) {
415   if ( $number =~ s/^\+$countrycode// ) {
416     $number = "0$number"
417       unless $number =~ /^1[389]/; #???
418   }
419   $number;
420 }
421
422 =item downstream_csv [ OPTION => VALUE, ... ]
423
424 =cut
425
426 my %export_names = (
427   'convergent'      => {},
428   'simple'  => {
429     'name'           => 'Simple',
430     'invoice_header' => "Date,Time,Name,Destination,Duration,Price",
431   },
432   'simple2' => {
433     'name'           => 'Simple with source',
434     'invoice_header' => "Date,Time,Called From,Destination,Duration,Price",
435                        #"Date,Time,Name,Called From,Destination,Duration,Price",
436   },
437   'default' => {
438     'name'           => 'Default',
439     'invoice_header' => 'Date,Time,Duration,Price,Number,Destination',
440   },
441   'source_default' => {
442     'name'           => 'Default with source',
443     'invoice_header' => 'Caller,Date,Time,Duration,Number,Destination,Price',
444   },
445 );
446
447 my %export_formats = (
448   'convergent' => [
449     'carriername', #CARRIER
450     sub { shift->_convergent_format('src') }, #SERVICE_NUMBER
451     sub { shift->_convergent_format('charged_party') }, #CHARGED_NUMBER
452     sub { time2str('%Y-%m-%d', shift->calldate_unix ) }, #DATE
453     sub { time2str('%T',       shift->calldate_unix ) }, #TIME
454     'billsec', #'duration', #DURATION
455     sub { shift->_convergent_format('dst') }, #NUMBER_DIALED
456     '', #XXX add (from prefixes in most recent email) #FROM_DESC
457     '', #XXX add (from prefixes in most recent email) #TO_DESC
458     'calltypename', #CLASS_CODE
459     'rated_price', #PRICE
460     sub { shift->rated_price ? 'Y' : 'N' }, #RATED
461     '', #OTHER_INFO
462   ],
463   'simple' => [
464     sub { time2str('%D', shift->calldate_unix ) },   #DATE
465     sub { time2str('%r', shift->calldate_unix ) },   #TIME
466     'userfield',                                     #USER
467     'dst',                                           #NUMBER_DIALED
468     sub { sprintf('%.2fm', shift->billsec / 60 ) },  #DURATION
469     sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
470   ],
471   'simple2' => [
472     sub { time2str('%D', shift->calldate_unix ) },   #DATE
473     sub { time2str('%r', shift->calldate_unix ) },   #TIME
474     #'userfield',                                     #USER
475     'dst',                                           #NUMBER_DIALED
476     'src',                                           #called from
477     sub { sprintf('%.2fm', shift->billsec / 60 ) },  #DURATION
478     sub { sprintf('%.3f', shift->upstream_price ) }, #PRICE
479   ],
480   'default' => [
481
482     #DATE
483     sub { time2str('%D', shift->calldate_unix ) },
484           # #time2str("%Y %b %d - %r", $cdr->calldate_unix ),
485
486     #TIME
487     sub { time2str('%r', shift->calldate_unix ) },
488           # 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
489
490     #DURATION
491     sub { my($cdr, %opt) = @_;
492           $opt{minutes}. ( $opt{granularity} ? 'm' : ' call' );
493         },
494
495     #PRICE
496     sub { my($cdr, %opt) = @_; $opt{money_char}. $opt{charge}; },
497
498     #DEST ("Number")
499     sub { my($cdr, %opt) = @_; $opt{pretty_dst} || $cdr->dst; },
500
501     #REGIONNAME ("Destination")
502     sub { my($cdr, %opt) = @_; $opt{dst_regionname}; },
503
504   ],
505 );
506 $export_formats{'source_default'} = [ 'src',
507                                       @{ $export_formats{'default'} }[0..2],
508                                       @{ $export_formats{'default'} }[4..5],
509                                       @{ $export_formats{'default'} }[3],
510                                     ];
511
512 sub downstream_csv {
513   my( $self, %opt ) = @_;
514
515   my $format = $opt{'format'}; # 'convergent';
516   return "Unknown format $format" unless exists $export_formats{$format};
517
518   #my $conf = new FS::Conf;
519   #$opt{'money_char'} ||= $conf->config('money_char') || '$';
520   $opt{'money_char'} ||= FS::Conf->new->config('money_char') || '$';
521
522   eval "use Text::CSV_XS;";
523   die $@ if $@;
524   my $csv = new Text::CSV_XS;
525
526   my @columns =
527     map {
528           ref($_) ? &{$_}($self, %opt) : $self->$_();
529         }
530     @{ $export_formats{$format} };
531
532   my $status = $csv->combine(@columns);
533   die "FS::CDR: error combining ". $csv->error_input(). "into downstream CSV"
534     unless $status;
535
536   $csv->string;
537
538 }
539
540 =back
541
542 =head1 CLASS METHODS
543
544 =over 4
545
546 =item invoice_formats
547
548 Returns an ordered list of key value pairs containing invoice format names
549 as keys (for use with part_pkg::voip_cdr) and "pretty" format names as values.
550
551 =cut
552
553 sub invoice_formats {
554   map { ($_ => $export_names{$_}->{'name'}) }
555     grep { $export_names{$_}->{'invoice_header'} }
556     keys %export_names;
557 }
558
559 =item invoice_header FORMAT
560
561 Returns a scalar containing the CSV column header for invoice format FORMAT.
562
563 =cut
564
565 sub invoice_header {
566   my $format = shift;
567   $export_names{$format}->{'invoice_header'};
568 }
569
570 =item import_formats
571
572 Returns an ordered list of key value pairs containing import format names
573 as keys (for use with batch_import) and "pretty" format names as values.
574
575 =cut
576
577 #false laziness w/part_pkg & part_export
578
579 my %cdr_info;
580 foreach my $INC ( @INC ) {
581   warn "globbing $INC/FS/cdr/*.pm\n" if $DEBUG;
582   foreach my $file ( glob("$INC/FS/cdr/*.pm") ) {
583     warn "attempting to load CDR format info from $file\n" if $DEBUG;
584     $file =~ /\/(\w+)\.pm$/ or do {
585       warn "unrecognized file in $INC/FS/cdr/: $file\n";
586       next;
587     };
588     my $mod = $1;
589     my $info = eval "use FS::cdr::$mod; ".
590                     "\\%FS::cdr::$mod\::info;";
591     if ( $@ ) {
592       die "error using FS::cdr::$mod (skipping): $@\n" if $@;
593       next;
594     }
595     unless ( keys %$info ) {
596       warn "no %info hash found in FS::cdr::$mod, skipping\n";
597       next;
598     }
599     warn "got CDR format info from FS::cdr::$mod: $info\n" if $DEBUG;
600     if ( exists($info->{'disabled'}) && $info->{'disabled'} ) {
601       warn "skipping disabled CDR format FS::cdr::$mod" if $DEBUG;
602       next;
603     }
604     $cdr_info{$mod} = $info;
605   }
606 }
607
608 tie my %import_formats, 'Tie::IxHash',
609   map  { $_ => $cdr_info{$_}->{'name'} }
610   sort { $cdr_info{$a}->{'weight'} <=> $cdr_info{$b}->{'weight'} }
611   grep { exists($cdr_info{$_}->{'import_fields'}) }
612   keys %cdr_info;
613
614 sub import_formats {
615   %import_formats;
616 }
617
618 sub _cdr_min_parser_maker {
619   my $field = shift;
620   my @fields = ref($field) ? @$field : ($field);
621   @fields = qw( billsec duration ) unless scalar(@fields) && $fields[0];
622   return sub {
623     my( $cdr, $min ) = @_;
624     my $sec = eval { _cdr_min_parse($min) };
625     die "error parsing seconds for @fields from $min minutes: $@\n" if $@;
626     $cdr->$_($sec) foreach @fields;
627   };
628 }
629
630 sub _cdr_min_parse {
631   my $min = shift;
632   sprintf('%.0f', $min * 60 );
633 }
634
635 sub _cdr_date_parser_maker {
636   my $field = shift;
637   my @fields = ref($field) ? @$field : ($field);
638   return sub {
639     my( $cdr, $datestring ) = @_;
640     my $unixdate = eval { _cdr_date_parse($datestring) };
641     die "error parsing date for @fields from $datestring: $@\n" if $@;
642     $cdr->$_($unixdate) foreach @fields;
643   };
644 }
645
646 sub _cdr_date_parse {
647   my $date = shift;
648
649   return '' unless length($date); #that's okay, it becomes NULL
650
651   my($year, $mon, $day, $hour, $min, $sec);
652
653   #$date =~ /^\s*(\d{4})[\-\/]\(\d{1,2})[\-\/](\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})\s*$/
654   #taqua  #2007-10-31 08:57:24.113000000
655
656   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|$)/ ) {
657     ($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
658   } 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|$)/ ) {
659     ($mon, $day, $year, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
660   } else {
661      die "unparsable date: $date"; #maybe we shouldn't die...
662   }
663
664   return '' if $year == 1900 && $mon == 1 && $day == 1
665             && $hour == 0    && $min == 0 && $sec == 0;
666
667   timelocal($sec, $min, $hour, $day, $mon-1, $year);
668 }
669
670 =item batch_import HASHREF
671
672 Imports CDR records.  Available options are:
673
674 =over 4
675
676 =item file
677
678 Filename
679
680 =item format
681
682 =item params
683
684 Hash reference of preset fields, typically cdrbatch
685
686 =item empty_ok
687
688 Set true to prevent throwing an error on empty imports
689
690 =back
691
692 =cut
693
694 my %import_options = (
695   'table'   => 'cdr',
696
697   'formats' => { map { $_ => $cdr_info{$_}->{'import_fields'}; }
698                      keys %cdr_info
699                },
700
701                           #drop the || 'csv' to allow auto xls for csv types?
702   'format_types' => { map { $_ => ( lc($cdr_info{$_}->{'type'}) || 'csv' ); }
703                           keys %cdr_info
704                     },
705
706   'format_headers' => { map { $_ => ( $cdr_info{$_}->{'header'} || 0 ); }
707                             keys %cdr_info
708                       },
709
710   'format_sep_chars' => { map { $_ => $cdr_info{$_}->{'sep_char'}; }
711                               keys %cdr_info
712                         },
713
714   'format_fixedlength_formats' =>
715     { map { $_ => $cdr_info{$_}->{'fixedlength_format'}; }
716           keys %cdr_info
717     },
718 );
719
720 sub _import_options {
721   \%import_options;
722 }
723
724 sub batch_import {
725   my $opt = shift;
726
727   my $iopt = _import_options;
728   $opt->{$_} = $iopt->{$_} foreach keys %$iopt;
729
730   FS::Record::batch_import( $opt );
731
732 }
733
734 =item process_batch_import
735
736 =cut
737
738 sub process_batch_import {
739   my $job = shift;
740
741   my $opt = _import_options;
742   $opt->{'params'} = [ 'format', 'cdrbatch' ];
743
744   FS::Record::process_batch_import( $job, $opt, @_ );
745
746 }
747 #  if ( $format eq 'simple' ) { #should be a callback or opt in FS::cdr::simple
748 #    @columns = map { s/^ +//; $_; } @columns;
749 #  }
750
751 =back
752
753 =head1 BUGS
754
755 =head1 SEE ALSO
756
757 L<FS::Record>, schema.html from the base documentation.
758
759 =cut
760
761 1;
762