show credit balance on invoices, #11564
[freeside.git] / FS / FS / rate_detail.pm
1 package FS::rate_detail;
2
3 use strict;
4 use vars qw( @ISA $DEBUG $me );
5 use FS::Record qw( qsearch qsearchs dbh );
6 use FS::rate;
7 use FS::rate_region;
8 use FS::rate_time;
9 use Tie::IxHash;
10
11 @ISA = qw(FS::Record);
12
13 $DEBUG = 0;
14 $me = '[FS::rate_detail]';
15
16 =head1 NAME
17
18 FS::rate_detail - Object methods for rate_detail records
19
20 =head1 SYNOPSIS
21
22   use FS::rate_detail;
23
24   $record = new FS::rate_detail \%hash;
25   $record = new FS::rate_detail { '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::rate_detail object represents an call plan rate.  FS::rate_detail
38 inherits from FS::Record.  The following fields are currently supported:
39
40 =over 4
41
42 =item ratedetailnum - primary key
43
44 =item ratenum - rate plan (see L<FS::rate>)
45
46 =item orig_regionnum - call origination region
47
48 =item dest_regionnum - call destination region
49
50 =item min_included - included minutes
51
52 =item min_charge - charge per minute
53
54 =item sec_granularity - granularity in seconds, i.e. 6 or 60; 0 for per-call
55
56 =item classnum - usage class (see L<FS::usage_class>) if any for this rate
57
58 =item ratetimenum - rating time period (see L<FS::rate_time) if any
59
60 =item cdrtypenum - CDR type (see L<FS::cdr_type>) if any for this rate
61
62 =back
63
64 =head1 METHODS
65
66 =over 4
67
68 =item new HASHREF
69
70 Creates a new call plan rate.  To add the call plan rate to the database, see
71 L<"insert">.
72
73 Note that this stores the hash reference, not a distinct copy of the hash it
74 points to.  You can ask the object for a copy with the I<hash> method.
75
76 =cut
77
78 # the new method can be inherited from FS::Record, if a table method is defined
79
80 sub table { 'rate_detail'; }
81
82 =item insert
83
84 Adds this record to the database.  If there is an error, returns the error,
85 otherwise returns false.
86
87 =cut
88
89 # the insert method can be inherited from FS::Record
90
91 =item delete
92
93 Delete this record from the database.
94
95 =cut
96
97 # the delete method can be inherited from FS::Record
98
99 =item replace OLD_RECORD
100
101 Replaces the OLD_RECORD with this one in the database.  If there is an error,
102 returns the error, otherwise returns false.
103
104 =cut
105
106 # the replace method can be inherited from FS::Record
107
108 =item check
109
110 Checks all fields to make sure this is a valid call plan rate.  If there is
111 an error, returns the error, otherwise returns false.  Called by the insert
112 and replace methods.
113
114 =cut
115
116 # the check method should currently be supplied - FS::Record contains some
117 # data checking routines
118
119 sub check {
120   my $self = shift;
121
122   my $error = 
123        $self->ut_numbern('ratedetailnum')
124     || $self->ut_foreign_key('ratenum', 'rate', 'ratenum')
125     || $self->ut_foreign_keyn('orig_regionnum', 'rate_region', 'regionnum' )
126     || $self->ut_foreign_key('dest_regionnum', 'rate_region', 'regionnum' )
127     || $self->ut_number('min_included')
128
129     #|| $self->ut_money('min_charge')
130     #good enough for now...
131     || $self->ut_float('min_charge')
132
133     || $self->ut_number('sec_granularity')
134
135     || $self->ut_foreign_keyn('classnum', 'usage_class', 'classnum' )
136   ;
137   return $error if $error;
138
139   $self->SUPER::check;
140 }
141
142 =item rate 
143
144 Returns the parent call plan (see L<FS::rate>) associated with this call plan
145 rate.
146
147 =cut
148
149 sub rate {
150   my $self = shift;
151   qsearchs('rate', { 'ratenum' => $self->ratenum } );
152 }
153
154 =item orig_region 
155
156 Returns the origination region (see L<FS::rate_region>) associated with this
157 call plan rate.
158
159 =cut
160
161 sub orig_region {
162   my $self = shift;
163   qsearchs('rate_region', { 'regionnum' => $self->orig_regionnum } );
164 }
165
166 =item dest_region 
167
168 Returns the destination region (see L<FS::rate_region>) associated with this
169 call plan rate.
170
171 =cut
172
173 sub dest_region {
174   my $self = shift;
175   qsearchs('rate_region', { 'regionnum' => $self->dest_regionnum } );
176 }
177
178 =item dest_regionname
179
180 Returns the name of the destination region (see L<FS::rate_region>) associated
181 with this call plan rate.
182
183 =cut
184
185 sub dest_regionname {
186   my $self = shift;
187   $self->dest_region->regionname;
188 }
189
190 =item dest_regionname
191
192 Returns a short list of the prefixes for the destination region
193 (see L<FS::rate_region>) associated with this call plan rate.
194
195 =cut
196
197 sub dest_prefixes_short {
198   my $self = shift;
199   $self->dest_region->prefixes_short;
200 }
201
202 =item rate_time
203
204 Returns the L<FS::rate_time> object associated with this call 
205 plan rate, if there is one.
206
207 =cut
208
209 sub rate_time {
210   my $self = shift;
211   $self->ratetimenum ? FS::rate_time->by_key($self->ratetimenum) : ();
212 }
213
214 =item rate_time_name
215
216 Returns the I<ratetimename> field of the L<FS::rate_time> object
217 associated with this rate plan.
218
219 =cut
220
221 sub rate_time_name {
222   my $self = shift;
223   $self->ratetimenum ? $self->rate_time->ratetimename : '(default)';
224 }
225
226 =item classname
227
228 Returns the name of the usage class (see L<FS::usage_class>) associated with
229 this call plan rate.
230
231 =cut
232
233 sub classname {
234   my $self = shift;
235   my $usage_class = qsearchs('usage_class', { classnum => $self->classnum });
236   $usage_class ? $usage_class->classname : '';
237 }
238
239 =item cdrtypename
240
241 Returns the name of the CDR type (see L<FS::cdr_type) associated with this 
242 rate, if there is one.  If not, returns the cdrtypenum itself.  This will 
243 only return an empty string if cdrtypenum is NULL.
244
245 =cut
246
247 sub cdrtypename {
248   my $self = shift;
249   my $cdrtypenum = $self->cdrtypenum or return '';
250   my $cdr_type = qsearchs('cdr_type', { cdrtypenum => $cdrtypenum });
251   return $cdr_type ? $cdr_type->cdrtypename : $cdrtypenum;
252 }
253
254 =back
255
256 =head1 SUBROUTINES
257
258 =over 4
259
260 =item granularities
261
262   Returns an (ordered) hash of granularity => name pairs
263
264 =cut
265
266 tie my %granularities, 'Tie::IxHash',
267   '1', => '1 second',
268   '6'  => '6 second',
269   '30' => '30 second', # '1/2 minute',
270   '60' => 'minute',
271   '0'  => 'call',
272 ;
273
274 sub granularities {
275   %granularities;
276 }
277
278 =item conn_secs
279
280   Returns an (ordered) hash of conn_sec => name pairs
281
282 =cut
283
284 tie my %conn_secs, 'Tie::IxHash',
285     '0' => 'connection',
286     '1' => 'first second',
287     '6' => 'first 6 seconds',
288    '30' => 'first 30 seconds', # '1/2 minute',
289    '60' => 'first minute',
290   '120' => 'first 2 minutes',
291   '180' => 'first 3 minutes',
292   '300' => 'first 5 minutes',
293 ;
294
295 sub conn_secs {
296   %conn_secs;
297 }
298
299 =item process_edit_import
300
301 =cut
302
303 use Storable qw(thaw);
304 use Data::Dumper;
305 use MIME::Base64;
306 sub process_edit_import {
307   my $job = shift;
308
309   #do we actually belong in rate_detail, like 'table' says?  even though we
310   # can possible create new rate records, that's a side effect, mostly we
311   # do edit rate_detail records in batch...
312
313   my $opt = { 'table'          => 'rate_detail',
314               'params'         => [], #required, apparantly
315               'formats'        => { 'default' => [
316                 'dest_regionnum',
317                 '', #regionname
318                 '', #country
319                 '', #prefixes
320                 #loop these
321                 'min_included',
322                 'min_charge',
323                 sub {
324                   my( $rate_detail, $g ) = @_;
325                   $g  = 0  if $g =~ /^\s*(per-)?call\s*$/i;
326                   $g  = 60 if $g =~ /^\s*minute\s*$/i;
327                   $g  =~ /^(\d+)/ or die "can't parse granularity: $g".
328                                          " for record ". Dumper($rate_detail);
329                   $rate_detail->sec_granularity($1);
330                 },
331                 'classnum',
332               ] },
333               'format_headers' => { 'default' => 1, },
334               'format_types'   => { 'default' => 'xls' },
335             };
336
337   #false laziness w/
338   #FS::Record::process_batch_import( $job, $opt, @_ );
339   
340   my $table = $opt->{table};
341   my @pass_params = @{ $opt->{params} };
342   my %formats = %{ $opt->{formats} };
343
344   my $param = thaw(decode_base64(shift));
345   warn Dumper($param) if $DEBUG;
346   
347   my $files = $param->{'uploaded_files'}
348     or die "No files provided.\n";
349
350   my (%files) = map { /^(\w+):([\.\w]+)$/ ? ($1,$2):() } split /,/, $files;
351
352   my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/';
353   my $file = $dir. $files{'file'};
354
355   my $error =
356     #false laziness w/
357     #FS::Record::batch_import( {
358     FS::rate_detail::edit_import( {
359       #class-static
360       table                      => $table,
361       formats                    => \%formats,
362       format_types               => $opt->{format_types},
363       format_headers             => $opt->{format_headers},
364       format_sep_chars           => $opt->{format_sep_chars},
365       format_fixedlength_formats => $opt->{format_fixedlength_formats},
366       #per-import
367       job                        => $job,
368       file                       => $file,
369       #type                       => $type,
370       format                     => $param->{format},
371       params                     => { map { $_ => $param->{$_} } @pass_params },
372       #?
373       default_csv                => $opt->{default_csv},
374     } );
375
376   unlink $file;
377
378   die "$error\n" if $error;
379
380 }
381
382 =item edit_import
383
384 =cut
385
386 #false laziness w/ #FS::Record::batch_import, grep "edit_import" for differences
387 #could be turned into callbacks or something
388 use Text::CSV_XS;
389 sub edit_import {
390   my $param = shift;
391
392   warn "$me edit_import call with params: \n". Dumper($param)
393     if $DEBUG;
394
395   my $table   = $param->{table};
396   my $formats = $param->{formats};
397
398   my $job     = $param->{job};
399   my $file    = $param->{file};
400   my $format  = $param->{'format'};
401   my $params  = $param->{params} || {};
402
403   die "unknown format $format" unless exists $formats->{ $format };
404
405   my $type = $param->{'format_types'}
406              ? $param->{'format_types'}{ $format }
407              : $param->{type} || 'csv';
408
409   unless ( $type ) {
410     if ( $file =~ /\.(\w+)$/i ) {
411       $type = lc($1);
412     } else {
413       #or error out???
414       warn "can't parse file type from filename $file; defaulting to CSV";
415       $type = 'csv';
416     }
417     $type = 'csv'
418       if $param->{'default_csv'} && $type ne 'xls';
419   }
420
421   my $header = $param->{'format_headers'}
422                  ? $param->{'format_headers'}{ $param->{'format'} }
423                  : 0;
424
425   my $sep_char = $param->{'format_sep_chars'}
426                    ? $param->{'format_sep_chars'}{ $param->{'format'} }
427                    : ',';
428
429   my $fixedlength_format =
430     $param->{'format_fixedlength_formats'}
431       ? $param->{'format_fixedlength_formats'}{ $param->{'format'} }
432       : '';
433
434   my @fields = @{ $formats->{ $format } };
435
436   my $row = 0;
437   my $count;
438   my $parser;
439   my @buffer = ();
440   my @header = (); #edit_import
441   if ( $type eq 'csv' || $type eq 'fixedlength' ) {
442
443     if ( $type eq 'csv' ) {
444
445       my %attr = ();
446       $attr{sep_char} = $sep_char if $sep_char;
447       $parser = new Text::CSV_XS \%attr;
448
449     } elsif ( $type eq 'fixedlength' ) {
450
451       eval "use Parse::FixedLength;";
452       die $@ if $@;
453       $parser = new Parse::FixedLength $fixedlength_format;
454  
455     } else {
456       die "Unknown file type $type\n";
457     }
458
459     @buffer = split(/\r?\n/, slurp($file) );
460     splice(@buffer, 0, ($header || 0) );
461     $count = scalar(@buffer);
462
463   } elsif ( $type eq 'xls' ) {
464
465     eval "use Spreadsheet::ParseExcel;";
466     die $@ if $@;
467
468     eval "use DateTime::Format::Excel;";
469     #for now, just let the error be thrown if it is used, since only CDR
470     # formats bill_west and troop use it, not other excel-parsing things
471     #die $@ if $@;
472
473     my $excel = Spreadsheet::ParseExcel::Workbook->new->Parse($file);
474
475     $parser = $excel->{Worksheet}[0]; #first sheet
476
477     $count = $parser->{MaxRow} || $parser->{MinRow};
478     $count++;
479
480     $row = $header || 0;
481
482     #edit_import - need some magic to parse the header
483     if ( $header ) {
484       my @header_row = @{ $parser->{Cells}[$0] };
485       @header = map $_->{Val}, @header_row;
486     }
487
488   } else {
489     die "Unknown file type $type\n";
490   }
491
492   #my $columns;
493
494   local $SIG{HUP} = 'IGNORE';
495   local $SIG{INT} = 'IGNORE';
496   local $SIG{QUIT} = 'IGNORE';
497   local $SIG{TERM} = 'IGNORE';
498   local $SIG{TSTP} = 'IGNORE';
499   local $SIG{PIPE} = 'IGNORE';
500
501   my $oldAutoCommit = $FS::UID::AutoCommit;
502   local $FS::UID::AutoCommit = 0;
503   my $dbh = dbh;
504
505   #edit_import - use the header to setup looping over different rates
506   my @rate = ();
507   if ( @header ) {
508     splice(@header,0,4); # # Region Country Prefixes
509     while ( my @next = splice(@header,0,4) ) {
510       my $rate;
511       if ( $next[0] =~ /^(\d+):\s*([^:]+):/ ) {
512         $rate = qsearchs('rate', { 'ratenum' => $1 } )
513           or die "unknown ratenum $1";
514       } elsif ( $next[0] =~ /^(NEW:)?\s*([^:]+)/i ) {
515         $rate = new FS::rate { 'ratename' => $2 };
516         my $error = $rate->insert;
517         if ( $error ) {
518           $dbh->rollback if $oldAutoCommit;
519           return "error inserting new rate: $error\n";
520         }
521       }
522       push @rate, $rate;
523     }
524   }
525   die unless @rate;
526   
527   my $line;
528   my $imported = 0;
529   my( $last, $min_sec ) = ( time, 5 ); #progressbar foo
530   while (1) {
531
532     my @columns = ();
533     if ( $type eq 'csv' ) {
534
535       last unless scalar(@buffer);
536       $line = shift(@buffer);
537
538       $parser->parse($line) or do {
539         $dbh->rollback if $oldAutoCommit;
540         return "can't parse: ". $parser->error_input();
541       };
542       @columns = $parser->fields();
543
544     } elsif ( $type eq 'fixedlength' ) {
545
546       @columns = $parser->parse($line);
547
548     } elsif ( $type eq 'xls' ) {
549
550       last if $row > ($parser->{MaxRow} || $parser->{MinRow})
551            || ! $parser->{Cells}[$row];
552
553       my @row = @{ $parser->{Cells}[$row] };
554       @columns = map $_->{Val}, @row;
555
556       #my $z = 'A';
557       #warn $z++. ": $_\n" for @columns;
558
559     } else {
560       die "Unknown file type $type\n";
561     }
562
563     #edit_import loop
564
565     my @repeat = @columns[0..3];
566
567     foreach my $rate ( @rate ) {
568
569       my @later = ();
570       my %hash = %$params;
571
572       foreach my $field ( @fields ) {
573
574         my $value = shift @columns;
575        
576         if ( ref($field) eq 'CODE' ) {
577           #&{$field}(\%hash, $value);
578           push @later, $field, $value;
579         #} else {
580         } elsif ($field) { #edit_import
581           #??? $hash{$field} = $value if length($value);
582           $hash{$field} = $value if defined($value) && length($value);
583         }
584
585       }
586
587       unshift @columns, @repeat; #edit_import put these back on for next time
588
589       my $class = "FS::$table";
590
591       my $record = $class->new( \%hash );
592
593       $record->ratenum($rate->ratenum); #edit_import
594
595       #edit_improt n/a my $param = {};
596       while ( scalar(@later) ) {
597         my $sub = shift @later;
598         my $data = shift @later;
599         #&{$sub}($record, $data, $conf, $param);# $record->&{$sub}($data, $conf);
600         &{$sub}($record, $data); #edit_import - don't have $conf
601         #edit_import wrong loop last if exists( $param->{skiprow} );
602       }
603       #edit_import wrong loop next if exists( $param->{skiprow} );
604
605       #edit_import update or insert, not just insert
606       my $old = qsearchs({
607         'table'   => $table,
608         'hashref' => { map { $_ => $record->$_() } qw(ratenum dest_regionnum) },
609       });
610
611       my $error;
612       if ( $old ) {
613         $record->ratedetailnum($old->ratedetailnum);
614         $error = $record->replace($old)
615       } else {
616         $record->insert;
617       }
618
619       if ( $error ) {
620         $dbh->rollback if $oldAutoCommit;
621         return "can't insert record". ( $line ? " for $line" : '' ). ": $error";
622       }
623
624     }
625
626     $row++;
627     $imported++;
628
629     if ( $job && time - $min_sec > $last ) { #progress bar
630       $job->update_statustext( int(100 * $imported / $count) );
631       $last = time;
632     }
633
634   }
635
636   $dbh->commit or die $dbh->errstr if $oldAutoCommit;;
637
638   return "Empty file!" unless $imported || $param->{empty_ok};
639
640   ''; #no error
641
642 }
643
644 =back
645
646 =head1 BUGS
647
648 =head1 SEE ALSO
649
650 L<FS::rate>, L<FS::rate_region>, L<FS::Record>,
651 schema.html from the base documentation.
652
653 =cut
654
655 1;
656