have the UI use full country names, and state names outside the US...
[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 =back
123
124 =item upstream_rateid - Upstream Rate ID
125
126 =item svcnum - Link to customer service (see L<FS::cust_svc>)
127
128 =item freesidestatus - NULL, done (or something)
129
130 =back
131
132 =head1 METHODS
133
134 =over 4
135
136 =item new HASHREF
137
138 Creates a new CDR.  To add the CDR to the database, see L<"insert">.
139
140 Note that this stores the hash reference, not a distinct copy of the hash it
141 points to.  You can ask the object for a copy with the I<hash> method.
142
143 =cut
144
145 # the new method can be inherited from FS::Record, if a table method is defined
146
147 sub table { 'cdr'; }
148
149 =item insert
150
151 Adds this record to the database.  If there is an error, returns the error,
152 otherwise returns false.
153
154 =cut
155
156 # the insert method can be inherited from FS::Record
157
158 =item delete
159
160 Delete this record from the database.
161
162 =cut
163
164 # the delete method can be inherited from FS::Record
165
166 =item replace OLD_RECORD
167
168 Replaces the OLD_RECORD with this one in the database.  If there is an error,
169 returns the error, otherwise returns false.
170
171 =cut
172
173 # the replace method can be inherited from FS::Record
174
175 =item check
176
177 Checks all fields to make sure this is a valid CDR.  If there is
178 an error, returns the error, otherwise returns false.  Called by the insert
179 and replace methods.
180
181 Note: Unlike most types of records, we don't want to "reject" a CDR and we want
182 to process them as quickly as possible, so we allow the database to check most
183 of the data.
184
185 =cut
186
187 sub check {
188   my $self = shift;
189
190 # we don't want to "reject" a CDR like other sorts of input...
191 #  my $error = 
192 #    $self->ut_numbern('acctid')
193 ##    || $self->ut_('calldate')
194 #    || $self->ut_text('clid')
195 #    || $self->ut_text('src')
196 #    || $self->ut_text('dst')
197 #    || $self->ut_text('dcontext')
198 #    || $self->ut_text('channel')
199 #    || $self->ut_text('dstchannel')
200 #    || $self->ut_text('lastapp')
201 #    || $self->ut_text('lastdata')
202 #    || $self->ut_numbern('startdate')
203 #    || $self->ut_numbern('answerdate')
204 #    || $self->ut_numbern('enddate')
205 #    || $self->ut_number('duration')
206 #    || $self->ut_number('billsec')
207 #    || $self->ut_text('disposition')
208 #    || $self->ut_number('amaflags')
209 #    || $self->ut_text('accountcode')
210 #    || $self->ut_text('uniqueid')
211 #    || $self->ut_text('userfield')
212 #    || $self->ut_numbern('cdrtypenum')
213 #    || $self->ut_textn('charged_party')
214 ##    || $self->ut_n('upstream_currency')
215 ##    || $self->ut_n('upstream_price')
216 #    || $self->ut_numbern('upstream_rateplanid')
217 ##    || $self->ut_n('distance')
218 #    || $self->ut_numbern('islocal')
219 #    || $self->ut_numbern('calltypenum')
220 #    || $self->ut_textn('description')
221 #    || $self->ut_numbern('quantity')
222 #    || $self->ut_numbern('carrierid')
223 #    || $self->ut_numbern('upstream_rateid')
224 #    || $self->ut_numbern('svcnum')
225 #    || $self->ut_textn('freesidestatus')
226 #  ;
227 #  return $error if $error;
228
229   #check the foreign keys even?
230   #do we want to outright *reject* the CDR?
231   my $error =
232        $self->ut_numbern('acctid')
233
234     #Usage = 1, S&E = 7, OC&C = 8
235     || $self->ut_foreign_keyn('cdrtypenum',  'cdr_type',     'cdrtypenum' )
236
237     #the big list in appendix 2
238     || $self->ut_foreign_keyn('calltypenum', 'cdr_calltype', 'calltypenum' )
239
240     # Telstra =1, Optus = 2, RSL COM = 3
241     || $self->ut_foreign_keyn('carrierid', 'cdr_carrier', 'carrierid' )
242   ;
243   return $error if $error;
244
245   $self->SUPER::check;
246 }
247
248 =item set_status_and_rated_price STATUS [ RATED_PRICE ]
249
250 Sets the status to the provided string.  If there is an error, returns the
251 error, otherwise returns false.
252
253 =cut
254
255 sub set_status_and_rated_price {
256   my($self, $status, $rated_price) = @_;
257   $self->status($status);
258   $self->rated_price($rated_price);
259   $self->replace();
260 }
261
262 =item calldate_unix 
263
264 Parses the calldate in SQL string format and returns a UNIX timestamp.
265
266 =cut
267
268 sub calldate_unix {
269   str2time(shift->calldate);
270 }
271
272 =item cdr_carrier
273
274 Returns the FS::cdr_carrier object associated with this CDR, or false if no
275 carrierid is defined.
276
277 =cut
278
279 my %carrier_cache = ();
280
281 sub cdr_carrier {
282   my $self = shift;
283   return '' unless $self->carrierid;
284   $carrier_cache{$self->carrierid} ||=
285     qsearchs('cdr_carrier', { 'carrierid' => $self->carrierid } );
286 }
287
288 =item carriername 
289
290 Returns the carrier name (see L<FS::cdr_carrier>), or the empty string if
291 no FS::cdr_carrier object is assocated with this CDR.
292
293 =cut
294
295 sub carriername {
296   my $self = shift;
297   my $cdr_carrier = $self->cdr_carrier;
298   $cdr_carrier ? $cdr_carrier->carriername : '';
299 }
300
301 =item cdr_calltype
302
303 Returns the FS::cdr_calltype object associated with this CDR, or false if no
304 calltypenum is defined.
305
306 =cut
307
308 my %calltype_cache = ();
309
310 sub cdr_calltype {
311   my $self = shift;
312   return '' unless $self->calltypenum;
313   $calltype_cache{$self->calltypenum} ||=
314     qsearchs('cdr_calltype', { 'calltypenum' => $self->calltypenum } );
315 }
316
317 =item calltypename 
318
319 Returns the call type name (see L<FS::cdr_calltype>), or the empty string if
320 no FS::cdr_calltype object is assocated with this CDR.
321
322 =cut
323
324 sub calltypename {
325   my $self = shift;
326   my $cdr_calltype = $self->cdr_calltype;
327   $cdr_calltype ? $cdr_calltype->calltypename : '';
328 }
329
330 =item cdr_upstream_rate
331
332 Returns the upstream rate mapping (see L<FS::cdr_upstream_rate>), or the empty
333 string if no FS::cdr_upstream_rate object is associated with this CDR.
334
335 =cut
336
337 sub cdr_upstream_rate {
338   my $self = shift;
339   return '' unless $self->upstream_rateid;
340   qsearchs('cdr_upstream_rate', { 'upstream_rateid' => $self->upstream_rateid })
341     or '';
342 }
343
344 =item _convergent_format COLUMN [ COUNTRYCODE ]
345
346 Returns the number in COLUMN formatted as follows:
347
348 If the country code does not match COUNTRYCODE (default "61"), it is returned
349 unchanged.
350
351 If the country code does match COUNTRYCODE (default "61"), it is removed.  In
352 addiiton, "0" is prepended unless the number starts with 13, 18 or 19. (???)
353
354 =cut
355
356 sub _convergent_format {
357   my( $self, $field ) = ( shift, shift );
358   my $countrycode = scalar(@_) ? shift : '61'; #+61 = australia
359   #my $number = $self->$field();
360   my $number = $self->get($field);
361   #if ( $number =~ s/^(\+|011)$countrycode// ) {
362   if ( $number =~ s/^\+$countrycode// ) {
363     $number = "0$number"
364       unless $number =~ /^1[389]/; #???
365   }
366   $number;
367 }
368
369 =item downstream_csv [ OPTION => VALUE, ... ]
370
371 =cut
372
373 my %export_formats = (
374   'convergent' => [
375     'carriername', #CARRIER
376     sub { shift->_convergent_format('src') }, #SERVICE_NUMBER
377     sub { shift->_convergent_format('charged_party') }, #CHARGED_NUMBER
378     sub { time2str('%Y-%m-%d', shift->calldate_unix ) }, #DATE
379     sub { time2str('%T',       shift->calldate_unix ) }, #TIME
380     'billsec', #'duration', #DURATION
381     sub { shift->_convergent_format('dst') }, #NUMBER_DIALED
382     '', #XXX add (from prefixes in most recent email) #FROM_DESC
383     '', #XXX add (from prefixes in most recent email) #TO_DESC
384     'calltypename', #CLASS_CODE
385     'rated_price', #PRICE
386     sub { shift->rated_price ? 'Y' : 'N' }, #RATED
387     '', #OTHER_INFO
388   ],
389 );
390
391 sub downstream_csv {
392   my( $self, %opt ) = @_;
393
394   my $format = $opt{'format'}; # 'convergent';
395   return "Unknown format $format" unless exists $export_formats{$format};
396
397   eval "use Text::CSV_XS;";
398   die $@ if $@;
399   my $csv = new Text::CSV_XS;
400
401   my @columns =
402     map {
403           ref($_) ? &{$_}($self) : $self->$_();
404         }
405     @{ $export_formats{$format} };
406
407   my $status = $csv->combine(@columns);
408   die "FS::CDR: error combining ". $csv->error_input(). "into downstream CSV"
409     unless $status;
410
411   $csv->string;
412
413 }
414
415 =back
416
417 =head1 CLASS METHODS
418
419 =over 4
420
421 =item batch_import
422
423 =cut
424
425 my %import_formats = (
426   'asterisk' => [
427     'accountcode',
428     'src',
429     'dst',
430     'dcontext',
431     'clid',
432     'channel',
433     'dstchannel',
434     'lastapp',
435     'lastdata',
436     'startdate', # XXX will need massaging
437     'answer',    # XXX same
438     'end',       # XXX same
439     'duration',
440     'billsec',
441     'disposition',
442     'amaflags',
443     'uniqueid',
444     'userfield',
445   ],
446   'unitel' => [
447     'uniqueid',
448     #'cdr_type',
449     'cdrtypenum',
450     'calldate', # may need massaging?  huh maybe not...
451     #'billsec', #XXX duration and billsec?
452                 sub { $_[0]->billsec(  $_[1] );
453                       $_[0]->duration( $_[1] );
454                     },
455     'src',
456     'dst', # XXX needs to have "+61" prepended unless /^\+/ ???
457     'charged_party',
458     'upstream_currency',
459     'upstream_price',
460     'upstream_rateplanid',
461     'distance',
462     'islocal',
463     'calltypenum',
464     'startdate',  #XXX needs massaging
465     'enddate',    #XXX same
466     'description',
467     'quantity',
468     'carrierid',
469     'upstream_rateid',
470   ]
471 );
472
473 sub batch_import {
474   my $param = shift;
475
476   my $fh = $param->{filehandle};
477   my $format = $param->{format};
478
479   return "Unknown format $format" unless exists $import_formats{$format};
480
481   eval "use Text::CSV_XS;";
482   die $@ if $@;
483
484   my $csv = new Text::CSV_XS;
485
486   my $imported = 0;
487   #my $columns;
488
489   local $SIG{HUP} = 'IGNORE';
490   local $SIG{INT} = 'IGNORE';
491   local $SIG{QUIT} = 'IGNORE';
492   local $SIG{TERM} = 'IGNORE';
493   local $SIG{TSTP} = 'IGNORE';
494   local $SIG{PIPE} = 'IGNORE';
495
496   my $oldAutoCommit = $FS::UID::AutoCommit;
497   local $FS::UID::AutoCommit = 0;
498   my $dbh = dbh;
499   
500   my $line;
501   while ( defined($line=<$fh>) ) {
502
503     $csv->parse($line) or do {
504       $dbh->rollback if $oldAutoCommit;
505       return "can't parse: ". $csv->error_input();
506     };
507
508     my @columns = $csv->fields();
509     #warn join('-',@columns);
510
511     my @later = ();
512     my %cdr =
513       map {
514
515         my $field_or_sub = $_;
516         if ( ref($field_or_sub) ) {
517           push @later, $field_or_sub, shift(@columns);
518           ();
519         } else {
520           ( $field_or_sub => shift @columns );
521         }
522
523       }
524       @{ $import_formats{$format} }
525     ;
526
527     my $cdr = new FS::cdr ( \%cdr );
528
529     while ( scalar(@later) ) {
530       my $sub = shift @later;
531       my $data = shift @later;
532       &{$sub}($cdr, $data);  # $cdr->&{$sub}($data); 
533     }
534
535     my $error = $cdr->insert;
536     if ( $error ) {
537       $dbh->rollback if $oldAutoCommit;
538       return $error;
539
540       #or just skip?
541       #next;
542     }
543
544     $imported++;
545   }
546
547   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
548
549   #might want to disable this if we skip records for any reason...
550   return "Empty file!" unless $imported;
551
552   '';
553
554 }
555
556 =back
557
558 =head1 BUGS
559
560 =head1 SEE ALSO
561
562 L<FS::Record>, schema.html from the base documentation.
563
564 =cut
565
566 1;
567