(finally) wrap up new tax rate engine (for now)
[freeside.git] / FS / FS / tax_class.pm
1 package FS::tax_class;
2
3 use strict;
4 use vars qw( @ISA );
5 use FS::UID qw(dbh);
6 use FS::Record qw( qsearch qsearchs );
7
8 @ISA = qw(FS::Record);
9
10 =head1 NAME
11
12 FS::tax_class - Object methods for tax_class records
13
14 =head1 SYNOPSIS
15
16   use FS::tax_class;
17
18   $record = new FS::tax_class \%hash;
19   $record = new FS::tax_class { 'column' => 'value' };
20
21   $error = $record->insert;
22
23   $error = $new_record->replace($old_record);
24
25   $error = $record->delete;
26
27   $error = $record->check;
28
29 =head1 DESCRIPTION
30
31 An FS::tax_class object represents a tax class.  FS::tax_class
32 inherits from FS::Record.  The following fields are currently supported:
33
34 =over 4
35
36 =item taxclassnum
37
38 Primary key
39
40 =item data_vendor
41
42 Vendor of the tax data
43
44 =item taxclass
45
46 Tax class
47
48 =item description
49
50 Human readable description of the tax class
51
52 =back
53
54 =head1 METHODS
55
56 =over 4
57
58 =item new HASHREF
59
60 Creates a new tax class.  To add the tax class to the database, see L<"insert">.
61
62 Note that this stores the hash reference, not a distinct copy of the hash it
63 points to.  You can ask the object for a copy with the I<hash> method.
64
65 =cut
66
67 sub table { 'tax_class'; }
68
69 =item insert
70
71 Adds this record to the database.  If there is an error, returns the error,
72 otherwise returns false.
73
74 =cut
75
76 =item delete
77
78 Delete this record from the database.
79
80 =cut
81
82 sub delete {
83   my $self = shift;
84
85   return "Can't delete a tax class which has tax rates!"
86     if qsearch( 'tax_rate', { 'taxclassnum' => $self->taxclassnum } );
87
88   return "Can't delete a tax class which has package tax rates!"
89     if qsearch( 'part_pkg_taxrate', { 'taxclassnum' => $self->taxclassnum } );
90
91   return "Can't delete a tax class which has package tax rates!"
92     if qsearch( 'part_pkg_taxrate', { 'taxclassnumtaxed' => $self->taxclassnum } );
93
94   return "Can't delete a tax class which has package tax overrides!"
95     if qsearch( 'part_pkg_taxoverride', { 'taxclassnum' => $self->taxclassnum } );
96
97   $self->SUPER::delete(@_);
98   
99 }
100
101 =item replace OLD_RECORD
102
103 Replaces the OLD_RECORD with this one in the database.  If there is an error,
104 returns the error, otherwise returns false.
105
106 =cut
107
108 =item check
109
110 Checks all fields to make sure this is a valid tax class.  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 sub check {
117   my $self = shift;
118
119   my $error = 
120     $self->ut_numbern('taxclassnum')
121     || $self->ut_text('taxclass')
122     || $self->ut_textn('data_vendor')
123     || $self->ut_textn('description')
124   ;
125   return $error if $error;
126
127   $self->SUPER::check;
128 }
129
130 =item batch_import
131
132 Loads part_pkg_taxrate records from an external CSV file.  If there is
133 an error, returns the error, otherwise returns false. 
134
135 =cut 
136
137 sub batch_import {
138   my ($param, $job) = @_;
139
140   my $fh = $param->{filehandle};
141   my $format = $param->{'format'};
142
143   my @fields;
144   my $hook;
145   my $endhook;
146   my $data = {};
147   my $imported = 0;
148   my $dbh = dbh;
149
150   my $line;
151   my ( $count, $last, $min_sec ) = (0, time, 5); #progressbar
152   if ( $job ) {
153     $count++
154       while ( defined($line=<$fh>) );
155     seek $fh, 0, 0;
156   }
157
158   if ( $format eq 'cch' || $format eq 'cch-update' ) {
159     @fields = qw( table name pos number length value description );
160     push @fields, 'actionflag' if $format eq 'cch-update';
161
162     $hook = sub { 
163       my $hash = shift;
164
165       if ($hash->{'table'} eq 'DETAIL') {
166         push @{$data->{'taxcat'}}, [ $hash->{'value'}, $hash->{'description'} ]
167           if ($hash->{'name'} eq 'TAXCAT' &&
168              (!exists($hash->{actionflag}) || $hash->{actionflag} eq 'I') );
169
170         push @{$data->{'taxtype'}}, [ $hash->{'value'}, $hash->{'description'} ]
171           if ($hash->{'name'} eq 'TAXTYPE' &&
172              (!exists($hash->{actionflag}) || $hash->{actionflag} eq 'I') );
173
174         if (exists($hash->{actionflag}) && $hash->{actionflag} eq 'D') {
175           my $name = $hash->{'name'};
176           my $value = $hash->{'value'};
177           return "Bad value for $name: $value"
178             unless $value =~ /^\d+$/;
179
180           if ($name eq 'TAXCAT' || $name eq 'TAXTYPE') {
181             my @tax_class = qsearch( 'tax_class',
182                                      { 'data_vendor' => 'cch' },
183                                      '',
184                                      "AND taxclass LIKE '".
185                                        ($name eq 'TAXTYPE' ? $value : '%').":".
186                                        ($name eq 'TAXCAT' ? $value : '%')."'",
187                                    );
188             foreach (@tax_class) {
189               my $error = $_->delete;
190               return $error if $error;
191             }
192           }
193         }
194
195       }
196
197       delete($hash->{$_})
198         for qw( data_vendor table name pos number length value description );
199       delete($hash->{actionflag}) if exists($hash->{actionflag});
200
201       '';
202
203     };
204
205     $endhook = sub { 
206
207       my $sql = "SELECT DISTINCT ".
208          "substring(taxclass from 1 for position(':' in taxclass)-1),".
209          "substring(description from 1 for position(':' in description)-1) ".
210          "FROM tax_class WHERE data_vendor='cch'";
211
212       my $sth = $dbh->prepare($sql) or die $dbh->errstr;
213       $sth->execute or die $sth->errstr;
214       my @old_types = @{$sth->fetchall_arrayref};
215
216       $sql = "SELECT DISTINCT ".
217          "substring(taxclass from position(':' in taxclass)+1),".
218          "substring(description from position(':' in description)+1) ".
219          "FROM tax_class WHERE data_vendor='cch'";
220
221       $sth = $dbh->prepare($sql) or die $dbh->errstr;
222       $sth->execute or die $sth->errstr;
223       my @old_cats = @{$sth->fetchall_arrayref};
224
225       my $catcount  = exists($data->{'taxcat'})  ? scalar(@{$data->{'taxcat'}})
226                                                  : 0;
227       my $typecount = exists($data->{'taxtype'}) ? scalar(@{$data->{'taxtype'}})
228                                                  : 0;
229
230       my $count = scalar(@old_types) * $catcount
231                 + $typecount * (scalar(@old_cats) + $catcount);
232
233       $imported = 1 if $format eq 'cch-update';  #empty file ok
234
235       foreach my $type (@old_types) {
236         foreach my $cat (@{$data->{'taxcat'}}) {
237
238           if ( $job ) {  # progress bar
239             if ( time - $min_sec > $last ) {
240               my $error = $job->update_statustext(
241                 int( 100 * $imported / $count )
242               );
243               die $error if $error;
244               $last = time;
245             }
246           }
247
248           my $tax_class =
249             new FS::tax_class( { 'data_vendor' => 'cch',
250                                  'taxclass'    => $type->[0].':'.$cat->[0],
251                                  'description' => $type->[1].':'.$cat->[1],
252                              } );
253           my $error = $tax_class->insert;
254           return $error if $error;
255           $imported++;
256         }
257       }
258
259       foreach my $type (@{$data->{'taxtype'}}) {
260         foreach my $cat (@old_cats, @{$data->{'taxcat'}}) {
261
262           if ( $job ) {  # progress bar
263             if ( time - $min_sec > $last ) {
264               my $error = $job->update_statustext(
265                 int( 100 * $imported / $count )
266               );
267               die $error if $error;
268               $last = time;
269             }
270           }
271
272           my $tax_class =
273             new FS::tax_class( { 'data_vendor' => 'cch',
274                                  'taxclass'    => $type->[0].':'.$cat->[0],
275                                  'description' => $type->[1].':'.$cat->[1],
276                              } );
277           my $error = $tax_class->insert;
278           return $error if $error;
279           $imported++;
280         }
281       }
282
283       '';
284     };
285
286   } elsif ( $format eq 'extended' ) {
287     die "unimplemented\n";
288     @fields = qw( );
289     $hook = sub {};
290   } else {
291     die "unknown format $format";
292   }
293
294   eval "use Text::CSV_XS;";
295   die $@ if $@;
296
297   my $csv = new Text::CSV_XS;
298
299   local $SIG{HUP} = 'IGNORE';
300   local $SIG{INT} = 'IGNORE';
301   local $SIG{QUIT} = 'IGNORE';
302   local $SIG{TERM} = 'IGNORE';
303   local $SIG{TSTP} = 'IGNORE';
304   local $SIG{PIPE} = 'IGNORE';
305
306   my $oldAutoCommit = $FS::UID::AutoCommit;
307   local $FS::UID::AutoCommit = 0;
308   
309   while ( defined($line=<$fh>) ) {
310
311     if ( $job ) {  # progress bar
312       if ( time - $min_sec > $last ) {
313         my $error = $job->update_statustext(
314           int( 100 * $imported / $count )
315         );
316         die $error if $error;
317         $last = time;
318       }
319     }
320
321     $csv->parse($line) or do {
322       $dbh->rollback if $oldAutoCommit;
323       return "can't parse: ". $csv->error_input();
324     };
325
326     my @columns = $csv->fields();
327
328     my %tax_class = ( 'data_vendor' => $format );
329     foreach my $field ( @fields ) {
330       $tax_class{$field} = shift @columns; 
331     }
332     if ( scalar( @columns ) ) {
333       $dbh->rollback if $oldAutoCommit;
334       return "Unexpected trailing columns in line (wrong format?): $line";
335     }
336
337     my $error = &{$hook}(\%tax_class);
338     if ( $error ) {
339       $dbh->rollback if $oldAutoCommit;
340       return $error;
341     }
342
343     next unless scalar(keys %tax_class);
344
345     my $tax_class = new FS::tax_class( \%tax_class );
346     $error = $tax_class->insert;
347     if ( $error ) {
348       $dbh->rollback if $oldAutoCommit;
349       return "can't insert tax_class for $line: $error";
350     }
351
352     $imported++;
353   }
354
355   my $error = &{$endhook}();
356   if ( $error ) {
357     $dbh->rollback if $oldAutoCommit;
358     return "can't insert tax_class for $line: $error";
359   }
360
361   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
362
363   return "Empty File!" unless $imported;
364
365   ''; #no error
366
367 }
368
369 =back
370
371 =head1 BUGS
372
373   batch_import does not handle mixed I and D records in the same file for
374   format cch-update
375
376 =head1 SEE ALSO
377
378 L<FS::Record>, schema.html from the base documentation.
379
380 =cut
381
382 1;
383
384