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