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