fix TeleAPI import (what kind of crack was Christopher smoking that he couldn't fix...
[freeside.git] / FS / FS / tax_rate_location.pm
1 package FS::tax_rate_location;
2
3 use strict;
4 use base qw( FS::Record );
5 use FS::Record qw( qsearch qsearchs dbh );
6 use FS::Misc qw( csv_from_fixed );
7
8 =head1 NAME
9
10 FS::tax_rate_location - Object methods for tax_rate_location records
11
12 =head1 SYNOPSIS
13
14   use FS::tax_rate_location;
15
16   $record = new FS::tax_rate_location \%hash;
17   $record = new FS::tax_rate_location { 'column' => 'value' };
18
19   $error = $record->insert;
20
21   $error = $new_record->replace($old_record);
22
23   $error = $record->delete;
24
25   $error = $record->check;
26
27 =head1 DESCRIPTION
28
29 An FS::tax_rate_location object represents a tax jurisdiction.  The only
30 functional field is "geocode", a foreign key to tax rates (L<FS::tax_rate>) 
31 that apply in the jurisdiction.  The city, county, state, and country fields 
32 are provided for description and reporting.
33
34 FS::tax_rate_location inherits from FS::Record.  The following fields are 
35 currently supported:
36
37 =over 4
38
39 =item taxratelocationnum - Primary key (assigned automatically for new 
40 tax_rate_locations)
41
42 =item data_vendor - The tax data vendor ('cch' or 'billsoft').
43
44 =item geocode - A unique geographic location code provided by the data vendor
45
46 =item city - City
47
48 =item county -  County
49
50 =item state - State (2-letter code)
51
52 =item country - Country (2-letter code, optional)
53
54 =item disabled - If 'Y' this record is no longer active.
55
56 =back
57
58 =head1 METHODS
59
60 =over 4
61
62 =item new HASHREF
63
64 Creates a new tax rate location.  To add the record to the database, see
65  L<"insert">.
66
67 Note that this stores the hash reference, not a distinct copy of the hash it
68 points to.  You can ask the object for a copy with the I<hash> method.
69
70 =cut
71
72 sub table { 'tax_rate_location'; }
73
74 =item insert
75
76 Adds this record to the database.  If there is an error, returns the error,
77 otherwise returns false.
78
79 =cut
80
81 =item delete
82
83 Delete this record from the database.
84
85 =cut
86
87 sub delete {
88   return "Can't delete tax rate locations.  Set disable to 'Y' instead.";
89   # check that it is unused in any cust_bill_pkg_tax_location records instead?
90 }
91
92 =item replace OLD_RECORD
93
94 Replaces the OLD_RECORD with this one in the database.  If there is an error,
95 returns the error, otherwise returns false.
96
97 =cut
98
99 =item check
100
101 Checks all fields to make sure this is a valid tax rate location.  If there is
102 an error, returns the error, otherwise returns false.  Called by the insert
103 and replace methods.
104
105 =cut
106
107 sub check {
108   my $self = shift;
109
110   my $error = 
111     $self->ut_numbern('taxratelocationnum')
112     || $self->ut_textn('data_vendor')
113     || $self->ut_alpha('geocode')
114     || $self->ut_textn('district')
115     || $self->ut_textn('city')
116     || $self->ut_textn('county')
117     || $self->ut_textn('state')
118     || $self->ut_enum('disabled', [ '', 'Y' ])
119   ;
120   return $error if $error;
121
122   my $t = '';
123   $t = $self->existing_search
124     unless $self->disabled;
125
126   $t = $self->by_key( $self->taxratelocationnum )
127     if !$t && $self->taxratelocationnum;
128
129   return "geocode ". $self->geocode. " already in use for this vendor"
130     if ( $t && $t->taxratelocationnum != $self->taxratelocationnum );
131
132   return "may only be disabled"
133     if ( $t && scalar( grep { $t->$_ ne $self->$_ } 
134                        grep { $_ ne 'disabled' }
135                        $self->fields
136                      )
137        );
138
139   $self->SUPER::check;
140 }
141
142 =item find_or_insert
143
144 Finds an existing, non-disabled tax jurisdiction matching the data_vendor 
145 and geocode fields. If there is one, updates its city, county, state, and
146 country to match this record.  If there is no existing record, inserts this 
147 record.
148
149 =cut
150
151 sub find_or_insert {
152   my $self = shift;
153   my $existing = $self->existing_search;
154   if ($existing) {
155     my $update = 0;
156     foreach (qw(city county state country)) {
157       if ($self->get($_) ne $existing->get($_)) {
158         $update++;
159       }
160     }
161     $self->set(taxratelocationnum => $existing->taxratelocationnum);
162     if ($update) {
163       return $self->replace($existing);
164     } else {
165       return;
166     }
167   } else {
168     return $self->insert;
169   }
170 }
171
172 sub existing_search {
173   my $self = shift;
174
175   qsearchs( 'tax_rate_location',
176             { disabled => '',
177               map { $_ => $self->$_ } qw( data_vendor geocode )
178             }
179           );
180 }
181
182 =back
183
184 =head1 CLASS METHODS
185
186 =item location_sql KEY => VALUE, ...
187
188 Returns an SQL fragment identifying matching tax_rate_location /
189 cust_bill_pkg_tax_rate_location records.
190
191 Parameters are county, state, city and locationtaxid
192
193 =cut
194
195 sub location_sql {
196   my($class, %param) = @_;
197
198   my %pn = (
199    'city'          => 'tax_rate_location.city',
200    'county'        => 'tax_rate_location.county',
201    'state'         => 'tax_rate_location.state',
202    'locationtaxid' => 'cust_bill_pkg_tax_rate_location.locationtaxid',
203   );
204
205   my %ph = map { $pn{$_} => dbh->quote($param{$_}) } keys %pn;
206
207   join( ' AND ',
208     map { "( $_ = $ph{$_} OR $ph{$_} = '' AND $_ IS NULL)" } keys %ph
209   );
210
211 }
212
213 =back
214
215 =head1 SUBROUTINES
216
217 =over 4
218
219 =item batch_import HASHREF, JOB
220
221 Starts importing tax_rate_location records from a file.  HASHREF must contain
222 'filehandle' (an open handle to the input file) and 'format' (one of 'cch',
223 'cch-fixed', 'cch-update', 'cch-fixed-update', or 'billsoft').  JOB is an
224 L<FS::queue> object to receive progress messages.
225
226 =cut
227
228 # XXX move this into TaxEngine modules at some point
229
230 sub batch_import {
231   my ($param, $job) = @_;
232
233   my $fh = $param->{filehandle};
234   my $format = $param->{'format'};
235
236   my %insert = ();
237   my %delete = ();
238
239   my @fields;
240   my $hook;
241
242   my @column_lengths = ();
243   my @column_callbacks = ();
244   if ( $format eq 'cch-fixed' || $format eq 'cch-fixed-update' ) {
245     $format =~ s/-fixed//;
246     my $trim = sub { my $r = shift; $r =~ s/^\s*//; $r =~ s/\s*$//; $r };
247     push @column_lengths, qw( 28 25 2 10 );
248     push @column_lengths, 1 if $format eq 'cch-update';
249     push @column_callbacks, $trim foreach (@column_lengths);
250   }
251
252   my $line;
253   my ( $count, $last, $min_sec ) = (0, time, 5); #progressbar
254   if ( $job || scalar(@column_callbacks) ) { # this makes zero sense
255     my $error =
256       csv_from_fixed(\$fh, \$count, \@column_lengths, \@column_callbacks);
257     return $error if $error;
258   }
259
260   if ( $format eq 'cch' || $format eq 'cch-update' ) {
261     @fields = qw( city county state geocode );
262     push @fields, 'actionflag' if $format eq 'cch-update';
263
264     $hook = sub {
265       my $hash = shift;
266
267       $hash->{'data_vendor'} ='cch';
268
269       if (exists($hash->{'actionflag'}) && $hash->{'actionflag'} eq 'D') {
270         delete($hash->{actionflag});
271
272         $hash->{disabled} = '';
273         my $tax_rate_location = qsearchs('tax_rate_location', $hash);
274         return "Can't find tax_rate_location to delete: ".
275                join(" ", map { "$_ => ". $hash->{$_} } @fields)
276           unless $tax_rate_location;
277
278         $tax_rate_location->disabled('Y');
279         my $error = $tax_rate_location->replace;
280         return $error if $error;
281
282         delete($hash->{$_}) foreach (keys %$hash);
283       }
284
285       delete($hash->{'actionflag'});
286
287       '';
288
289     };
290
291   } elsif ( $format eq 'billsoft' ) {
292     @fields = ( qw( geocode alt_location country state county city ), '', '' );
293
294     $hook = sub {
295       my $hash = shift;
296       if ($hash->{alt_location}) {
297         # don't import these; the jurisdiction should be named using its 
298         # primary city
299         %$hash = ();
300         return;
301       }
302
303       $hash->{data_vendor} = 'billsoft';
304       # unlike cust_tax_location, keep the whole-country and whole-state 
305       # rows, but strip the whitespace
306       $hash->{county} =~ s/^ //g;
307       $hash->{state} =~ s/^ //g;
308       $hash->{country} =~ s/^ //g;
309       $hash->{city} =~ s/[^\w ]//g; # remove asterisks and other bad things
310       $hash->{country} = substr($hash->{country}, 0, 2);
311       '';
312     }
313
314   } elsif ( $format eq 'extended' ) {
315     die "unimplemented\n";
316     @fields = qw( );
317     $hook = sub {};
318   } else {
319     die "unknown format $format";
320   }
321
322   eval "use Text::CSV_XS;";
323   die $@ if $@;
324
325   my $csv = new Text::CSV_XS;
326
327   my $imported = 0;
328
329   local $SIG{HUP} = 'IGNORE';
330   local $SIG{INT} = 'IGNORE';
331   local $SIG{QUIT} = 'IGNORE';
332   local $SIG{TERM} = 'IGNORE';
333   local $SIG{TSTP} = 'IGNORE';
334   local $SIG{PIPE} = 'IGNORE';
335
336   my $oldAutoCommit = $FS::UID::AutoCommit;
337   local $FS::UID::AutoCommit = 0;
338   my $dbh = dbh;
339
340   while ( defined($line=<$fh>) ) {
341     $csv->parse($line) or do {
342       $dbh->rollback if $oldAutoCommit;
343       return "can't parse: ". $csv->error_input();
344     };
345
346     if ( $job ) {  # progress bar
347       if ( time - $min_sec > $last ) {
348         my $error = $job->update_statustext(
349           int( 100 * $imported / $count ) .
350           ',Creating tax jurisdiction records'
351         );
352         die $error if $error;
353         $last = time;
354       }
355     }
356
357     my @columns = $csv->fields();
358
359     my %tax_rate_location = ();
360     foreach my $field ( @fields ) {
361       $tax_rate_location{$field} = shift @columns;
362     }
363     if ( scalar( @columns ) ) {
364       $dbh->rollback if $oldAutoCommit;
365       return "Unexpected trailing columns in line (wrong format?) importing tax-rate_location: $line";
366     }
367
368     my $error = &{$hook}(\%tax_rate_location);
369     if ( $error ) {
370       $dbh->rollback if $oldAutoCommit;
371       return $error;
372     }
373
374     if (scalar(keys %tax_rate_location)) { #inserts only
375
376       my $tax_rate_location = new FS::tax_rate_location( \%tax_rate_location );
377       $error = $tax_rate_location->insert;
378
379       if ( $error ) {
380         $dbh->rollback if $oldAutoCommit;
381         return "can't insert tax_rate_location for $line: $error";
382       }
383
384     }
385
386     $imported++;
387
388   }
389
390   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
391
392   return "Empty file!" unless ($imported || $format eq 'cch-update');
393
394   ''; #no error
395
396 }
397
398 sub _upgrade_data {
399 #actually no, we want to leave those records behind now that they're giving us
400 # geo_state etc.
401 #  my $class = shift;
402 #
403 #  my $sql = "UPDATE tax_rate_location SET data_vendor = 'compliance_solutions' WHERE data_vendor = 'compliance solutions'";
404 #
405 #  my $sth = dbh->prepare($sql) or die $DBI::errstr;
406 #  $sth->execute() or die $sth->errstr;
407 }
408
409 =head1 BUGS
410
411 Currently somewhat specific to CCH supplied data.
412
413 =head1 SEE ALSO
414
415 L<FS::Record>, schema.html from the base documentation.
416
417 =cut
418
419 1;
420