svc_phone service and CDR billing from imported CDRs
[freeside.git] / FS / FS / cdr.pm
1 package FS::cdr;
2
3 use strict;
4 use vars qw( @ISA );
5 use Date::Parse;
6 use Date::Format;
7 use Time::Local;
8 use FS::UID qw( dbh );
9 use FS::Record qw( qsearch qsearchs );
10 use FS::cdr_type;
11 use FS::cdr_calltype;
12 use FS::cdr_carrier;
13 use FS::cdr_upstream_rate;
14
15 @ISA = qw(FS::Record);
16
17 =head1 NAME
18
19 FS::cdr - Object methods for cdr records
20
21 =head1 SYNOPSIS
22
23   use FS::cdr;
24
25   $record = new FS::cdr \%hash;
26   $record = new FS::cdr { 'column' => 'value' };
27
28   $error = $record->insert;
29
30   $error = $new_record->replace($old_record);
31
32   $error = $record->delete;
33
34   $error = $record->check;
35
36 =head1 DESCRIPTION
37
38 An FS::cdr object represents an Call Data Record, typically from a telephony
39 system or provider of some sort.  FS::cdr inherits from FS::Record.  The
40 following fields are currently supported:
41
42 =over 4
43
44 =item acctid - primary key
45
46 =item calldate - Call timestamp (SQL timestamp)
47
48 =item clid - Caller*ID with text
49
50 =item src - Caller*ID number / Source number
51
52 =item dst - Destination extension
53
54 =item dcontext - Destination context
55
56 =item channel - Channel used
57
58 =item dstchannel - Destination channel if appropriate
59
60 =item lastapp - Last application if appropriate
61
62 =item lastdata - Last application data
63
64 =item startdate - Start of call (UNIX-style integer timestamp)
65
66 =item answerdate - Answer time of call (UNIX-style integer timestamp)
67
68 =item enddate - End time of call (UNIX-style integer timestamp)
69
70 =item duration - Total time in system, in seconds
71
72 =item billsec - Total time call is up, in seconds
73
74 =item disposition - What happened to the call: ANSWERED, NO ANSWER, BUSY 
75
76 =item amaflags - What flags to use: BILL, IGNORE etc, specified on a per channel basis like accountcode. 
77
78 =cut
79
80   #ignore the "omit" and "documentation" AMAs??
81   #AMA = Automated Message Accounting. 
82   #default: Sets the system default. 
83   #omit: Do not record calls. 
84   #billing: Mark the entry for billing 
85   #documentation: Mark the entry for documentation.
86
87 =back
88
89 =item accountcode - CDR account number to use: account
90
91 =item uniqueid - Unique channel identifier (Unitel/RSLCOM Event ID)
92
93 =item userfield - CDR user-defined field
94
95 =item cdr_type - CDR type - see L<FS::cdr_type> (Usage = 1, S&E = 7, OC&C = 8)
96
97 =item charged_party - Service number to be billed
98
99 =item upstream_currency - Wholesale currency from upstream
100
101 =item upstream_price - Wholesale price from upstream
102
103 =item upstream_rateplanid - Upstream rate plan ID
104
105 =item rated_price - Rated (or re-rated) price
106
107 =item distance - km (need units field?)
108
109 =item islocal - Local - 1, Non Local = 0
110
111 =item calltypenum - Type of call - see L<FS::cdr_calltype>
112
113 =item description - Description (cdr_type 7&8 only) (used for cust_bill_pkg.itemdesc)
114
115 =item quantity - Number of items (cdr_type 7&8 only)
116
117 =item carrierid - Upstream Carrier ID (see L<FS::cdr_carrier>) 
118
119 =cut
120
121 #Telstra =1, Optus = 2, RSL COM = 3
122
123 =item upstream_rateid - Upstream Rate ID
124
125 =item svcnum - Link to customer service (see L<FS::cust_svc>)
126
127 =item freesidestatus - NULL, done (or something)
128
129 =back
130
131 =head1 METHODS
132
133 =over 4
134
135 =item new HASHREF
136
137 Creates a new CDR.  To add the CDR to the database, see L<"insert">.
138
139 Note that this stores the hash reference, not a distinct copy of the hash it
140 points to.  You can ask the object for a copy with the I<hash> method.
141
142 =cut
143
144 # the new method can be inherited from FS::Record, if a table method is defined
145
146 sub table { 'cdr'; }
147
148 =item insert
149
150 Adds this record to the database.  If there is an error, returns the error,
151 otherwise returns false.
152
153 =cut
154
155 # the insert method can be inherited from FS::Record
156
157 =item delete
158
159 Delete this record from the database.
160
161 =cut
162
163 # the delete method can be inherited from FS::Record
164
165 =item replace OLD_RECORD
166
167 Replaces the OLD_RECORD with this one in the database.  If there is an error,
168 returns the error, otherwise returns false.
169
170 =cut
171
172 # the replace method can be inherited from FS::Record
173
174 =item check
175
176 Checks all fields to make sure this is a valid CDR.  If there is
177 an error, returns the error, otherwise returns false.  Called by the insert
178 and replace methods.
179
180 Note: Unlike most types of records, we don't want to "reject" a CDR and we want
181 to process them as quickly as possible, so we allow the database to check most
182 of the data.
183
184 =cut
185
186 sub check {
187   my $self = shift;
188
189 # we don't want to "reject" a CDR like other sorts of input...
190 #  my $error = 
191 #    $self->ut_numbern('acctid')
192 ##    || $self->ut_('calldate')
193 #    || $self->ut_text('clid')
194 #    || $self->ut_text('src')
195 #    || $self->ut_text('dst')
196 #    || $self->ut_text('dcontext')
197 #    || $self->ut_text('channel')
198 #    || $self->ut_text('dstchannel')
199 #    || $self->ut_text('lastapp')
200 #    || $self->ut_text('lastdata')
201 #    || $self->ut_numbern('startdate')
202 #    || $self->ut_numbern('answerdate')
203 #    || $self->ut_numbern('enddate')
204 #    || $self->ut_number('duration')
205 #    || $self->ut_number('billsec')
206 #    || $self->ut_text('disposition')
207 #    || $self->ut_number('amaflags')
208 #    || $self->ut_text('accountcode')
209 #    || $self->ut_text('uniqueid')
210 #    || $self->ut_text('userfield')
211 #    || $self->ut_numbern('cdrtypenum')
212 #    || $self->ut_textn('charged_party')
213 ##    || $self->ut_n('upstream_currency')
214 ##    || $self->ut_n('upstream_price')
215 #    || $self->ut_numbern('upstream_rateplanid')
216 ##    || $self->ut_n('distance')
217 #    || $self->ut_numbern('islocal')
218 #    || $self->ut_numbern('calltypenum')
219 #    || $self->ut_textn('description')
220 #    || $self->ut_numbern('quantity')
221 #    || $self->ut_numbern('carrierid')
222 #    || $self->ut_numbern('upstream_rateid')
223 #    || $self->ut_numbern('svcnum')
224 #    || $self->ut_textn('freesidestatus')
225 #  ;
226 #  return $error if $error;
227
228   $self->calldate( $self->startdate_sql )
229     if !$self->calldate && $self->startdate;
230
231   unless ( $self->charged_party ) {
232     if ( $self->dst =~ /^(\+?1)?8[02-8]{2}/ ) {
233       $self->charged_party($self->dst);
234     } else {
235       $self->charged_party($self->src);
236     }
237   }
238
239   #check the foreign keys even?
240   #do we want to outright *reject* the CDR?
241   my $error =
242        $self->ut_numbern('acctid')
243
244     #Usage = 1, S&E = 7, OC&C = 8
245     || $self->ut_foreign_keyn('cdrtypenum',  'cdr_type',     'cdrtypenum' )
246
247     #the big list in appendix 2
248     || $self->ut_foreign_keyn('calltypenum', 'cdr_calltype', 'calltypenum' )
249
250     # Telstra =1, Optus = 2, RSL COM = 3
251     || $self->ut_foreign_keyn('carrierid', 'cdr_carrier', 'carrierid' )
252   ;
253   return $error if $error;
254
255   $self->SUPER::check;
256 }
257
258 =item set_status_and_rated_price STATUS [ RATED_PRICE ]
259
260 Sets the status to the provided string.  If there is an error, returns the
261 error, otherwise returns false.
262
263 =cut
264
265 sub set_status_and_rated_price {
266   my($self, $status, $rated_price) = @_;
267   $self->freesidestatus($status);
268   $self->rated_price($rated_price);
269   $self->replace();
270 }
271
272 =item calldate_unix 
273
274 Parses the calldate in SQL string format and returns a UNIX timestamp.
275
276 =cut
277
278 sub calldate_unix {
279   str2time(shift->calldate);
280 }
281
282 =item startdate_sql
283
284 Parses the startdate in UNIX timestamp format and returns a string in SQL
285 format.
286
287 =cut
288
289 sub startdate_sql {
290   my($sec,$min,$hour,$mday,$mon,$year) = localtime(shift->startdate);
291   $mon++;
292   $year += 1900;
293   "$year-$mon-$mday $hour:$min:$sec";
294 }
295
296 =item cdr_carrier
297
298 Returns the FS::cdr_carrier object associated with this CDR, or false if no
299 carrierid is defined.
300
301 =cut
302
303 my %carrier_cache = ();
304
305 sub cdr_carrier {
306   my $self = shift;
307   return '' unless $self->carrierid;
308   $carrier_cache{$self->carrierid} ||=
309     qsearchs('cdr_carrier', { 'carrierid' => $self->carrierid } );
310 }
311
312 =item carriername 
313
314 Returns the carrier name (see L<FS::cdr_carrier>), or the empty string if
315 no FS::cdr_carrier object is assocated with this CDR.
316
317 =cut
318
319 sub carriername {
320   my $self = shift;
321   my $cdr_carrier = $self->cdr_carrier;
322   $cdr_carrier ? $cdr_carrier->carriername : '';
323 }
324
325 =item cdr_calltype
326
327 Returns the FS::cdr_calltype object associated with this CDR, or false if no
328 calltypenum is defined.
329
330 =cut
331
332 my %calltype_cache = ();
333
334 sub cdr_calltype {
335   my $self = shift;
336   return '' unless $self->calltypenum;
337   $calltype_cache{$self->calltypenum} ||=
338     qsearchs('cdr_calltype', { 'calltypenum' => $self->calltypenum } );
339 }
340
341 =item calltypename 
342
343 Returns the call type name (see L<FS::cdr_calltype>), or the empty string if
344 no FS::cdr_calltype object is assocated with this CDR.
345
346 =cut
347
348 sub calltypename {
349   my $self = shift;
350   my $cdr_calltype = $self->cdr_calltype;
351   $cdr_calltype ? $cdr_calltype->calltypename : '';
352 }
353
354 =item cdr_upstream_rate
355
356 Returns the upstream rate mapping (see L<FS::cdr_upstream_rate>), or the empty
357 string if no FS::cdr_upstream_rate object is associated with this CDR.
358
359 =cut
360
361 sub cdr_upstream_rate {
362   my $self = shift;
363   return '' unless $self->upstream_rateid;
364   qsearchs('cdr_upstream_rate', { 'upstream_rateid' => $self->upstream_rateid })
365     or '';
366 }
367
368 =item _convergent_format COLUMN [ COUNTRYCODE ]
369
370 Returns the number in COLUMN formatted as follows:
371
372 If the country code does not match COUNTRYCODE (default "61"), it is returned
373 unchanged.
374
375 If the country code does match COUNTRYCODE (default "61"), it is removed.  In
376 addiiton, "0" is prepended unless the number starts with 13, 18 or 19. (???)
377
378 =cut
379
380 sub _convergent_format {
381   my( $self, $field ) = ( shift, shift );
382   my $countrycode = scalar(@_) ? shift : '61'; #+61 = australia
383   #my $number = $self->$field();
384   my $number = $self->get($field);
385   #if ( $number =~ s/^(\+|011)$countrycode// ) {
386   if ( $number =~ s/^\+$countrycode// ) {
387     $number = "0$number"
388       unless $number =~ /^1[389]/; #???
389   }
390   $number;
391 }
392
393 =item downstream_csv [ OPTION => VALUE, ... ]
394
395 =cut
396
397 my %export_formats = (
398   'convergent' => [
399     'carriername', #CARRIER
400     sub { shift->_convergent_format('src') }, #SERVICE_NUMBER
401     sub { shift->_convergent_format('charged_party') }, #CHARGED_NUMBER
402     sub { time2str('%Y-%m-%d', shift->calldate_unix ) }, #DATE
403     sub { time2str('%T',       shift->calldate_unix ) }, #TIME
404     'billsec', #'duration', #DURATION
405     sub { shift->_convergent_format('dst') }, #NUMBER_DIALED
406     '', #XXX add (from prefixes in most recent email) #FROM_DESC
407     '', #XXX add (from prefixes in most recent email) #TO_DESC
408     'calltypename', #CLASS_CODE
409     'rated_price', #PRICE
410     sub { shift->rated_price ? 'Y' : 'N' }, #RATED
411     '', #OTHER_INFO
412   ],
413 );
414
415 sub downstream_csv {
416   my( $self, %opt ) = @_;
417
418   my $format = $opt{'format'}; # 'convergent';
419   return "Unknown format $format" unless exists $export_formats{$format};
420
421   eval "use Text::CSV_XS;";
422   die $@ if $@;
423   my $csv = new Text::CSV_XS;
424
425   my @columns =
426     map {
427           ref($_) ? &{$_}($self) : $self->$_();
428         }
429     @{ $export_formats{$format} };
430
431   my $status = $csv->combine(@columns);
432   die "FS::CDR: error combining ". $csv->error_input(). "into downstream CSV"
433     unless $status;
434
435   $csv->string;
436
437 }
438
439 =back
440
441 =head1 CLASS METHODS
442
443 =over 4
444
445 =item batch_import
446
447 =cut
448
449 my($tmp_mday, $tmp_mon, $tmp_year);
450
451 my %import_formats = (
452   'asterisk' => [
453     'accountcode',
454     'src',
455     'dst',
456     'dcontext',
457     'clid',
458     'channel',
459     'dstchannel',
460     'lastapp',
461     'lastdata',
462     'startdate', # XXX will need massaging
463     'answer',    # XXX same
464     'end',       # XXX same
465     'duration',
466     'billsec',
467     'disposition',
468     'amaflags',
469     'uniqueid',
470     'userfield',
471   ],
472   'unitel' => [
473     'uniqueid',
474     #'cdr_type',
475     'cdrtypenum',
476     'calldate', # may need massaging?  huh maybe not...
477     #'billsec', #XXX duration and billsec?
478                 sub { $_[0]->billsec(  $_[1] );
479                       $_[0]->duration( $_[1] );
480                     },
481     'src',
482     'dst', # XXX needs to have "+61" prepended unless /^\+/ ???
483     'charged_party',
484     'upstream_currency',
485     'upstream_price',
486     'upstream_rateplanid',
487     'distance',
488     'islocal',
489     'calltypenum',
490     'startdate',  #XXX needs massaging
491     'enddate',    #XXX same
492     'description',
493     'quantity',
494     'carrierid',
495     'upstream_rateid',
496   ],
497   'ams' => [
498
499     # Date
500     sub { my($cdr, $date) = @_;
501           $date =~ /^(\d{1,2})\/(\d{1,2})\/(\d\d(\d\d)?)$/
502             or die "unparsable date: $date"; #maybe we shouldn't die...
503           #$cdr->startdate( timelocal(0, 0, 0 ,$2, $1-1, $3) );
504           ($tmp_mday, $tmp_mon, $tmp_year) = ( $2, $1-1, $3 );
505         },
506
507     # Time
508     sub { my($cdr, $time) = @_;
509           #my($sec, $min, $hour, $mday, $mon, $year)= localtime($cdr->startdate);
510           $time =~ /^(\d{1,2}):(\d{1,2}):(\d{1,2})$/
511             or die "unparsable time: $time"; #maybe we shouldn't die...
512           #$cdr->startdate( timelocal($3, $2, $1 ,$mday, $mon, $year) );
513           $cdr->startdate(
514             timelocal($3, $2, $1 ,$tmp_mday, $tmp_mon, $tmp_year)
515           );
516         },
517
518     # Source_Number
519     'src',
520
521     # Terminating_Number
522     'dst',
523
524     # Duration
525     sub { my($cdr, $min) = @_;
526           my $sec = sprintf('%.0f', $min * 60 );
527           $cdr->billsec(  $sec );
528           $cdr->duration( $sec );
529         },
530
531   ],
532 );
533
534 sub batch_import {
535   my $param = shift;
536
537   my $fh = $param->{filehandle};
538   my $format = $param->{format};
539
540   return "Unknown format $format" unless exists $import_formats{$format};
541
542   eval "use Text::CSV_XS;";
543   die $@ if $@;
544
545   my $csv = new Text::CSV_XS;
546
547   my $imported = 0;
548   #my $columns;
549
550   local $SIG{HUP} = 'IGNORE';
551   local $SIG{INT} = 'IGNORE';
552   local $SIG{QUIT} = 'IGNORE';
553   local $SIG{TERM} = 'IGNORE';
554   local $SIG{TSTP} = 'IGNORE';
555   local $SIG{PIPE} = 'IGNORE';
556
557   my $oldAutoCommit = $FS::UID::AutoCommit;
558   local $FS::UID::AutoCommit = 0;
559   my $dbh = dbh;
560
561   if ( $format eq 'ams' ) { # and other formats with a header too?
562
563   }
564
565   my $body = 0;
566   my $line;
567   while ( defined($line=<$fh>) ) {
568
569     #skip header...
570     if ( ! $body++ && $format eq 'ams' && $line =~ /^[\w\, ]+$/ ) {
571       next;
572     }
573
574     $csv->parse($line) or do {
575       $dbh->rollback if $oldAutoCommit;
576       return "can't parse: ". $csv->error_input();
577     };
578
579     my @columns = $csv->fields();
580     #warn join('-',@columns);
581
582     if ( $format eq 'ams' ) {
583       @columns = map { s/^ +//; $_; } @columns;
584     }
585
586     my @later = ();
587     my %cdr =
588       map {
589
590         my $field_or_sub = $_;
591         if ( ref($field_or_sub) ) {
592           push @later, $field_or_sub, shift(@columns);
593           ();
594         } else {
595           ( $field_or_sub => shift @columns );
596         }
597
598       }
599       @{ $import_formats{$format} }
600     ;
601
602     my $cdr = new FS::cdr ( \%cdr );
603
604     while ( scalar(@later) ) {
605       my $sub = shift @later;
606       my $data = shift @later;
607       &{$sub}($cdr, $data);  # $cdr->&{$sub}($data); 
608     }
609
610     my $error = $cdr->insert;
611     if ( $error ) {
612       $dbh->rollback if $oldAutoCommit;
613       return $error;
614
615       #or just skip?
616       #next;
617     }
618
619     $imported++;
620   }
621
622   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
623
624   #might want to disable this if we skip records for any reason...
625   return "Empty file!" unless $imported;
626
627   '';
628
629 }
630
631 =back
632
633 =head1 BUGS
634
635 =head1 SEE ALSO
636
637 L<FS::Record>, schema.html from the base documentation.
638
639 =cut
640
641 1;
642