RT# 83450 - fixed rateplan export
[freeside.git] / FS / FS / part_pkg_taxrate.pm
1 package FS::part_pkg_taxrate;
2
3 use strict;
4 use vars qw( @ISA );
5 use Date::Parse;
6 use DateTime;
7 use DateTime::Format::Strptime;
8 use FS::Record qw( qsearch qsearchs dbh );
9 use FS::part_pkg_taxproduct;
10 use FS::Misc qw(csv_from_fixed);
11
12 @ISA = qw(FS::Record);
13
14 =head1 NAME
15
16 FS::part_pkg_taxrate - Object methods for part_pkg_taxrate records
17
18 =head1 SYNOPSIS
19
20   use FS::part_pkg_taxrate;
21
22   $record = new FS::part_pkg_taxrate \%hash;
23   $record = new FS::part_pkg_taxrate { 'column' => 'value' };
24
25   $error = $record->insert;
26
27   $error = $new_record->replace($old_record);
28
29   $error = $record->delete;
30
31   $error = $record->check;
32
33 =head1 DESCRIPTION
34
35 An FS::part_pkg_taxrate object maps packages onto tax rates.
36 FS::part_pkg_taxrate inherits from FS::Record.  The following fields are
37 currently supported:
38
39 =over 4
40
41 =item pkgtaxratenum
42
43 Primary key
44
45 =item data_vendor
46
47 Tax data vendor
48
49 =item geocode
50
51 Tax vendor location code
52
53 =item taxproductnum
54
55 Class of package for tax purposes, Index into FS::part_pkg_taxproduct
56
57 =item city
58
59 city
60
61 =item county
62
63 county
64
65 =item state
66
67 state
68
69 =item local
70
71 local
72
73 =item country
74
75 country
76
77 =item taxclassnum
78
79 Class of tax index into FS::tax_taxclass and FS::tax_rate
80
81 =item taxclassnumtaxed
82
83 Class of tax taxed by this entry.
84
85 =item taxable
86
87 taxable
88
89 =item effdate
90
91 effdate
92
93 =back
94
95 =head1 METHODS
96
97 =over 4
98
99 =item new HASHREF
100
101 Creates a new customer (location), package, tax rate mapping.  To add the
102 mapping to the database, see L<"insert">.
103
104 Note that this stores the hash reference, not a distinct copy of the hash it
105 points to.  You can ask the object for a copy with the I<hash> method.
106
107 =cut
108
109 sub table { 'part_pkg_taxrate'; }
110
111 =item insert
112
113 Adds this record to the database.  If there is an error, returns the error,
114 otherwise returns false.
115
116 =cut
117
118 =item delete
119
120 Delete this record from the database.
121
122 =cut
123
124 =item replace OLD_RECORD
125
126 Replaces the OLD_RECORD with this one in the database.  If there is an error,
127 returns the error, otherwise returns false.
128
129 =cut
130
131 =item check
132
133 Checks all fields to make sure this is a valid tax rate mapping.  If there is
134 an error, returns the error, otherwise returns false.  Called by the insert
135 and replace methods.
136
137 =cut
138
139 sub check {
140   my $self = shift;
141
142   my $error = 
143     $self->ut_numbern('pkgtaxratenum')
144     || $self->ut_textn('data_vendor')
145     || $self->ut_textn('geocode')
146     || $self->
147          ut_foreign_key('taxproductnum', 'part_pkg_taxproduct', 'taxproductnum')
148     || $self->ut_textn('city')
149     || $self->ut_textn('county')
150     || $self->ut_textn('state')
151     || $self->ut_textn('local')
152     || $self->ut_text('country')
153     || $self->ut_foreign_keyn('taxclassnumtaxed', 'tax_class', 'taxclassnum')
154     || $self->ut_foreign_key('taxclassnum', 'tax_class', 'taxclassnum')
155     || $self->ut_snumbern('effdate')
156     || $self->ut_enum('taxable', [ 'Y', '' ])
157   ;
158   return $error if $error;
159
160   $self->SUPER::check;
161 }
162
163 =item batch_import
164
165 Loads part_pkg_taxrate records from an external CSV file.  If there is
166 an error, returns the error, otherwise returns false. 
167
168 =cut 
169
170 sub batch_import {
171   my ($param, $job) = @_;
172
173   my $fh = $param->{filehandle};
174   my $format = $param->{'format'};
175
176   my $imported = 0;
177   my @fields;
178   my $hook;
179
180   my @column_lengths = ();
181   my @column_callbacks = ();
182   if ( $format eq 'cch-fixed' || $format eq 'cch-fixed-update' ) {
183     $format =~ s/-fixed//;
184     my $date_format = sub { my $r='';
185                             /^(\d{4})(\d{2})(\d{2})$/ && ($r="$3/$2/$1");
186                             $r;
187                           };
188     $column_callbacks[16] = $date_format;
189     push @column_lengths, qw( 28 25 2 1 10 4 30 3 100 2 2 2 2 1 2 2 8 1 );
190     push @column_lengths, 1 if $format eq 'cch-update';
191   }
192
193   my $line;
194   my ( $count, $last, $min_sec ) = (0, time, 5); #progressbar
195   if ( $job || scalar(@column_callbacks) ) {
196     my $error =
197       csv_from_fixed(\$fh, \$count, \@column_lengths, \@column_callbacks);
198     return $error if $error;
199   }
200
201   if ( $format eq 'cch' ||  $format eq 'cch-update' ) {
202     @fields = qw( city county state local geocode group groupdesc item
203                   itemdesc provider customer taxtypetaxed taxcattaxed
204                   taxable taxtype taxcat effdate rectype );
205     push @fields, 'actionflag' if $format eq 'cch-update';
206
207     $imported++ if $format eq 'cch-update';  #empty file ok
208
209     $hook = sub { 
210       my $hash = shift;
211
212       unless ( $hash->{'rectype'} eq 'R' or $hash->{'rectype'} eq 'T' ) {
213         delete($hash->{$_}) for (keys %$hash);
214         return;
215       }
216
217       $hash->{'data_vendor'} = 'cch';
218
219       my %providers = ( '00' => 'Regulated LEC',
220                         '01' => 'Regulated IXC',
221                         '02' => 'Unregulated LEC',
222                         '03' => 'Unregulated IXC',
223                         '04' => 'ISP',
224                         '05' => 'Wireless',
225                       );
226
227       my %customers = ( '00' => 'Residential',
228                         '01' => 'Commercial',
229                         '02' => 'Industrial',
230                         '09' => 'Lifeline',
231                         '10' => 'Senior Citizen',
232                       );
233
234       my $taxproduct =
235         join(':', map{ $hash->{$_} } qw(group item provider customer ) );
236
237       my %part_pkg_taxproduct = ( 'data_vendor' => 'cch', 
238                                   'taxproduct' => $taxproduct,
239                                 );
240
241       my $part_pkg_taxproduct = qsearchs( 'part_pkg_taxproduct', 
242                                           { %part_pkg_taxproduct }
243                                         );
244
245       unless ($part_pkg_taxproduct) {
246         if (($hash->{'actionfield'} || '') eq 'D') {
247           warn "WARNING: Can't find part_pkg_taxproduct for txmatrix deletion: ".
248             join(" ", map { "$_ => ". $hash->{$_} } @fields) .
249             " (ignored)\n";
250           next;
251         }
252
253         $part_pkg_taxproduct{'description'} = 
254           join(' : ', (map{ $hash->{$_} } qw(groupdesc itemdesc)),
255                       $providers{$hash->{'provider'}} || '',
256                       $customers{$hash->{'customer'}} || '',
257               );
258         $part_pkg_taxproduct = new FS::part_pkg_taxproduct \%part_pkg_taxproduct;
259         my $error = $part_pkg_taxproduct->insert;
260         return "Error inserting tax product (part_pkg_taxproduct): $error"
261           if $error;
262
263       }
264       $hash->{'taxproductnum'} = $part_pkg_taxproduct->taxproductnum;
265
266       delete($hash->{$_})
267         for qw(group groupdesc item itemdesc provider customer rectype );
268       
269       # resolve the taxtype/taxcat fields to taxclassnums
270
271       my %map = ( 'taxclassnum'      => [ 'taxtype', 'taxcat' ],
272                   'taxclassnumtaxed' => [ 'taxtypetaxed', 'taxcattaxed' ],
273                 );
274
275       for my $item (keys %map) {
276         my $class = join(':', map($hash->{$_}, @{$map{$item}}));
277         my $tax_class =
278           qsearchs( 'tax_class',
279                     { data_vendor => 'cch',
280                       'taxclass' => $class,
281                     }
282                   );
283         if ( $tax_class ) {
284           $hash->{$item} = $tax_class->taxclassnum;
285         } elsif ($class ne ':' and ($hash->{actionflag} || '') eq 'D') {
286           # return "Can't find tax class for txmatrix deletion: ".
287           warn "WARNING: Can't find tax class $class for txmatrix deletion (ignored)\n";
288           return ''; # don't delete the record, then
289         }
290
291         delete($hash->{$_}) foreach @{$map{$item}};
292       }
293
294       my $parser = new DateTime::Format::Strptime( pattern => "%m/%d/%Y",
295                                                    time_zone => 'floating',
296                                                  );
297       my $dt = $parser->parse_datetime( $hash->{'effdate'} );
298       return "Can't parse effdate ". $hash->{'effdate'}. ': '. $parser->errstr
299         unless $dt;
300       $hash->{'effdate'} = $dt->epoch;
301  
302       $hash->{'country'} = 'US'; # CA is available
303
304       $hash->{'taxable'} = '' if ($hash->{'taxable'} eq 'N');
305
306       if (exists($hash->{actionflag}) && $hash->{actionflag} eq 'D') {
307         delete($hash->{actionflag});
308
309         foreach my $intfield (qw( taxproductnum taxclassnum effdate )) {
310           if ( $hash->{$intfield} eq '' ) {
311             return "$intfield is empty in search! -- ".
312                    join(" ", map { "$_ => *". $hash->{$_}. '*' } keys(%$hash) );
313           }
314         }
315
316         my @part_pkg_taxrate = qsearch('part_pkg_taxrate', $hash);
317         unless ( scalar(@part_pkg_taxrate) || $param->{'delete_only'} ) {
318           if ( $hash->{taxproductnum} ) {
319             my $taxproduct =
320               qsearchs( 'part_pkg_taxproduct',
321                         { 'taxproductnum' => $hash->{taxproductnum} }
322                       );
323             $hash->{taxproductnum} .= ' ( '. $taxproduct->taxproduct. ' )'
324               if $taxproduct;
325           }
326           warn "WARNING: Can't find part_pkg_taxrate to delete: ".
327                  join(" ", map { "$_ => *". $hash->{$_}. '*' } keys(%$hash) );
328         }
329
330         foreach my $part_pkg_taxrate (@part_pkg_taxrate) {
331           my $error = $part_pkg_taxrate->delete;
332           return $error if $error;
333         }
334
335         delete($hash->{$_}) foreach (keys %$hash);
336       }
337
338       delete($hash->{actionflag});
339
340       '';
341     };
342
343   } elsif ( $format eq 'extended' ) {
344     die "unimplemented\n";
345     @fields = qw( );
346     $hook = sub {};
347   } else {
348     die "unknown format $format";
349   }
350
351   eval "use Text::CSV_XS;";
352   die $@ if $@;
353
354   my $csv = new Text::CSV_XS;
355
356   local $SIG{HUP} = 'IGNORE';
357   local $SIG{INT} = 'IGNORE';
358   local $SIG{QUIT} = 'IGNORE';
359   local $SIG{TERM} = 'IGNORE';
360   local $SIG{TSTP} = 'IGNORE';
361   local $SIG{PIPE} = 'IGNORE';
362
363   my $oldAutoCommit = $FS::UID::AutoCommit;
364   local $FS::UID::AutoCommit = 0;
365   my $dbh = dbh;
366   
367   while ( defined($line=<$fh>) ) {
368     $csv->parse($line) or do {
369       $dbh->rollback if $oldAutoCommit;
370       return "can't parse: ". $csv->error_input();
371     };
372
373
374     if ( $job ) {  # progress bar
375       if ( time - $min_sec > $last ) {
376         my $error = $job->update_statustext(
377           int( 100 * $imported / $count ). ",Importing tax matrix"
378         );
379         die $error if $error;
380         $last = time;
381       }
382     }
383
384     my @columns = $csv->fields();
385
386     my %part_pkg_taxrate = ( 'data_vendor' => $format );
387     foreach my $field ( @fields ) {
388       $part_pkg_taxrate{$field} = shift @columns; 
389     }
390     if ( scalar( @columns ) ) {
391       $dbh->rollback if $oldAutoCommit;
392       return "Unexpected trailing columns in line (wrong format?) importing part_pkg_taxrate: $line";
393     }
394
395     my $error = &{$hook}(\%part_pkg_taxrate);
396     if ( $error ) {
397       $dbh->rollback if $oldAutoCommit;
398       return $error;
399     }
400     next unless scalar(keys %part_pkg_taxrate);
401
402
403     my $part_pkg_taxrate = new FS::part_pkg_taxrate( \%part_pkg_taxrate );
404     $error = $part_pkg_taxrate->insert;
405
406     if ( $error ) {
407       $dbh->rollback if $oldAutoCommit;
408       return "can't insert part_pkg_taxrate for $line: $error";
409     }
410
411     $imported++;
412   }
413
414   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
415
416   return "Empty file!" unless ( $imported || $format eq 'cch-update' );
417
418   ''; #no error
419
420 }
421
422 =back
423
424 =head1 BUGS
425
426 =head1 SEE ALSO
427
428 L<FS::Record>, schema.html from the base documentation.
429
430 =cut
431
432 1;
433
434