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