backup the schema for tables we don't need the data from. RT#85959
[freeside.git] / FS / FS / cust_tax_location.pm
1 package FS::cust_tax_location;
2
3 use strict;
4 use vars qw( @ISA );
5 use FS::Record qw( qsearch qsearchs dbh );
6 use FS::Misc qw ( csv_from_fixed );
7
8 @ISA = qw(FS::Record);
9
10 =head1 NAME
11
12 FS::cust_tax_location - Object methods for cust_tax_location records
13
14 =head1 SYNOPSIS
15
16   use FS::cust_tax_location;
17
18   $record = new FS::cust_tax_location \%hash;
19   $record = new FS::cust_tax_location { '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::cust_tax_location object represents a classification rule for
32 determining a tax region code ('geocode') for a service location.  These
33 records are used when editing customer locations to help the user choose the
34 correct tax jurisdiction code.  The jurisdiction codes are actually defined
35 in L<FS::tax_rate_location>, and appear directly in records in
36 L<FS::tax_rate>.
37
38 FS::cust_tax_location is used in tax calculation (for CCH) to determine 
39 "implied" geocodes for customers and locations that have a complete U.S.
40 ZIP+4 code and thus can be exactly placed in a jurisdiction.  For those that
41 don't, the user is expected to choose the geocode when entering the customer
42 record.
43
44 FS::cust_tax_location inherits from FS::Record.  The following fields are
45 currently supported:
46
47 =over 4
48
49 =item custlocationnum - primary key
50
51 =item data_vendor - a tax data vendor and "style" of record
52
53 =item country - the two-letter country code
54
55 =item state - the two-letter state code (though CCH uses this differently;
56 see QUIRKS)
57
58 =item zip - an exact zip code (again, see QUIRKS)
59
60 =item ziplo -  the lower bound of the zip code range (requires zip to be null)
61
62 =item ziphi - the upper bound of the zip code range (requires zip to be null)
63
64 =item plus4lo - the lower bound of the last 4 zip code digits
65
66 =item plus4hi - the upper bound of the last 4 zip code digits
67
68 =item default_location - 'Y' when this record represents the default.  The UI
69 will list default locations before non-default locations.
70
71 =item geocode - the foreign key into L<FS::part_pkg_tax_rate>, 
72 L<FS::tax_rate>, L<FS::tax_rate_location>, etc.
73
74 =back
75
76 =head1 METHODS
77
78 =over 4
79
80 =item new HASHREF
81
82 Creates a new cust_tax_location.  To add the cust_tax_location to the database,
83 see L<"insert">.
84
85 Note that this stores the hash reference, not a distinct copy of the hash it
86 points to.  You can ask the object for a copy with the I<hash> method.
87
88 =cut
89
90 sub table { 'cust_tax_location'; }
91
92 =item insert
93
94 Adds this record to the database.  If there is an error, returns the error,
95 otherwise returns false.
96
97 =cut
98
99 =item delete
100
101 Delete this record from the database.
102
103 =cut
104
105 =item replace OLD_RECORD
106
107 Replaces the OLD_RECORD with this one in the database.  If there is an error,
108 returns the error, otherwise returns false.
109
110 =cut
111
112 =item check
113
114 Checks all fields to make sure this is a valid cust_tax_location.  If there is
115 an error, returns the error, otherwise returns false.  Called by the insert
116 and replace methods.
117
118 =cut
119
120 sub check {
121   my $self = shift;
122
123   my $error = 
124     $self->ut_numbern('custlocationnum')
125     || $self->ut_text('data_vendor')
126     || $self->ut_textn('city')
127     || $self->ut_textn('postalcity')
128     || $self->ut_textn('county')
129     || $self->ut_text('state')
130     || $self->ut_numbern('plus4hi')
131     || $self->ut_numbern('plus4lo')
132     || $self->ut_enum('default_location', [ '', 'Y' ] )
133     || $self->ut_enum('cityflag', [ '', 'I', 'O', 'B' ] )
134     || $self->ut_alpha('geocode')
135   ;
136   if ( $self->country ) {
137     $error ||= $self->ut_country('country')
138            ||  $self->ut_zip('ziphi', $self->country)
139            ||  $self->ut_zip('ziplo', $self->country);
140   }
141   return $error if $error;
142
143   if ($self->state eq 'CN' && $self->data_vendor eq 'cch-zip' ) {
144     $error = "Illegal canadian zip"
145      unless $self->zip =~ /^[A-Z]$/;
146   } elsif ($self->state =~ /^E([B-DFGILNPR-UW])$/ && $self->data_vendor eq 'cch-zip' ) {
147     $error = "Illegal european zip"
148      unless $self->zip =~ /^E$1$/;
149   } elsif ($self->data_vendor =~ /^cch/) {
150     $error = $self->ut_numbern('zip', $self->state eq 'CN' ? 'CA' : 'US');
151   }
152   return $error if $error;
153
154   $self->SUPER::check;
155 }
156
157 # annoyingly incompatible with FS::Record::batch_import.
158
159 sub batch_import {
160   my ($param, $job) = @_;
161
162   my $fh = $param->{filehandle};
163   my $format = $param->{'format'};
164
165   my $imported = 0;
166   my @fields;
167   my $hook;
168
169   my @column_lengths = ();
170   my @column_callbacks = ();
171   if ( $format =~ /^cch-fixed/ ) {
172     $format =~ s/-fixed//;
173     my $f = $format;
174     my $update = 0;
175     $f =~ s/-update// && ($update = 1);
176     if ($f eq 'cch') {
177       push @column_lengths, qw( 5 2 4 4 10 1 );
178     } elsif ( $f eq 'cch-zip' ) {
179       push @column_lengths, qw( 5 28 25 2 28 5 1 1 10 1 2 );
180     } else {
181       return "Unknown format: $format";
182     }
183     push @column_lengths, 1 if $update;
184   }
185
186   my $line;
187   my ( $count, $last, $min_sec ) = (0, time, 5); #progressbar
188   if ( $job || scalar(@column_lengths) ) {
189     my $error = csv_from_fixed(\$fh, \$count, \@column_lengths);
190     return $error if $error;
191   }
192
193   if ( $format eq 'cch' || $format eq 'cch-update' ) {
194     @fields = qw( zip state plus4lo plus4hi geocode default_location );
195     push @fields, 'actionflag' if $format eq 'cch-update';
196
197     $imported++ if $format eq 'cch-update'; #empty file ok
198     
199     $hook = sub {
200       my $hash = shift;
201
202       $hash->{'data_vendor'} = 'cch';
203       $hash->{'default_location'} =~ s/ //g;
204
205       if (exists($hash->{actionflag}) && $hash->{actionflag} eq 'D') {
206         delete($hash->{actionflag});
207
208         my @cust_tax_location = qsearch('cust_tax_location', $hash);
209         return "Can't find cust_tax_location to delete: ".
210                join(" ", map { "$_ => ". $hash->{$_} } @fields)
211           unless scalar(@cust_tax_location) || $param->{'delete_only'} ;
212
213         foreach my $cust_tax_location (@cust_tax_location) {
214           my $error = $cust_tax_location->delete;
215           return $error if $error;
216         }
217
218         delete($hash->{$_}) foreach (keys %$hash);
219       }
220
221       delete($hash->{'actionflag'});
222
223       '';
224       
225     };
226
227   } elsif ( $format eq 'cch-zip' || $format eq 'cch-update-zip' ) {
228     @fields = qw( zip city county state postalcity countyfips countydef default_location geocode cityflag unique );
229     push @fields, 'actionflag' if $format eq 'cch-update-zip';
230
231     $imported++ if $format eq 'cch-update'; #empty file ok
232     
233     $hook = sub {
234       my $hash = shift;
235
236       $hash->{'data_vendor'} = 'cch-zip';
237       delete($hash->{$_}) foreach qw( countyfips countydef unique );
238
239       $hash->{'cityflag'} =~ s/ //g;
240       $hash->{'default_location'} =~ s/ //g;
241
242       if (exists($hash->{actionflag}) && $hash->{actionflag} eq 'D') {
243         delete($hash->{actionflag});
244
245         my @cust_tax_location = qsearch('cust_tax_location', $hash);
246         return "Can't find cust_tax_location to delete: ".
247                join(" ", map { "$_ => ". $hash->{$_} } @fields)
248           unless scalar(@cust_tax_location) || $param->{'delete_only'} ;
249
250         foreach my $cust_tax_location (@cust_tax_location) {
251           my $error = $cust_tax_location->delete;
252           return $error if $error;
253         }
254
255         delete($hash->{$_}) foreach (keys %$hash);
256       }
257
258       delete($hash->{'actionflag'});
259
260       '';
261       
262     };
263
264   } elsif ( $format eq 'billsoft' ) {
265
266     @fields = qw( geocode alt_location country state county city 
267                   ziplo ziphi );
268     $hook = sub {
269       my $hash = shift;
270       $hash->{data_vendor} = 'billsoft';
271       $hash->{default_location} = ($hash->{alt_location} ? '' : 'Y');
272       $hash->{city} =~ s/[^\w ]//g; # remove asterisks and other bad things
273       $hash->{country} = substr($hash->{country}, 0, 2);
274       if (    $hash->{state} =~ /^ *$/
275            or $hash->{county} =~ /^ *$/
276            or $hash->{country} !~ /^US|CA$/ ) {
277         # remove whole-country rows, whole-state rows, and non-CAN/USA rows
278         %$hash = ();
279       }
280       '';
281     };
282
283   } elsif ( $format eq 'extended' ) {
284     die "unimplemented\n";
285     @fields = qw( );
286   } else {
287     die "unknown format $format";
288   }
289
290   eval "use Text::CSV_XS;";
291   die $@ if $@;
292
293   my $csv = new Text::CSV_XS;
294
295   local $SIG{HUP} = 'IGNORE';
296   local $SIG{INT} = 'IGNORE';
297   local $SIG{QUIT} = 'IGNORE';
298   local $SIG{TERM} = 'IGNORE';
299   local $SIG{TSTP} = 'IGNORE';
300   local $SIG{PIPE} = 'IGNORE';
301
302   my $oldAutoCommit = $FS::UID::AutoCommit;
303   local $FS::UID::AutoCommit = 0;
304   my $dbh = dbh;
305   
306   while ( defined($line=<$fh>) ) {
307     $csv->parse($line) or do {
308       $dbh->rollback if $oldAutoCommit;
309       return "can't parse: ". $csv->error_input();
310     };
311
312     if ( $job ) {  # progress bar
313       if ( time - $min_sec > $last ) {
314         my $error = $job->update_statustext(
315           int( 100 * $imported / $count ). ",Importing tax locations"
316         );
317         die $error if $error;
318         $last = time;
319       }
320     }
321
322     my @columns = $csv->fields();
323
324     my %cust_tax_location = ( 'data_vendor' => $format );;
325     foreach my $field ( @fields ) {
326       $cust_tax_location{$field} = shift @columns; 
327     }
328     if ( scalar( @columns ) ) {
329       $dbh->rollback if $oldAutoCommit;
330       return "Unexpected trailing columns in line (wrong format?) importing cust_tax_location: $line";
331     }
332
333     my $error = &{$hook}(\%cust_tax_location);
334     if ( $error ) {
335       $dbh->rollback if $oldAutoCommit;
336       return $error;
337     }
338
339     # $hook can delete the contents of the hash to prevent the row from 
340     # being inserted
341     next unless scalar(keys %cust_tax_location);
342
343     my $cust_tax_location = new FS::cust_tax_location( \%cust_tax_location );
344     $error = $cust_tax_location->insert;
345
346     if ( $error ) {
347       $dbh->rollback if $oldAutoCommit;
348       return "can't insert cust_tax_location for $line: $error";
349     }
350
351     $imported++;
352   }
353
354   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
355
356   return "Empty file!" unless ( $imported || $format =~ /^cch-update/ );
357
358   ''; #no error
359
360 }
361
362 =back
363
364 =head1 SUBROUTINES
365
366 =over 4
367
368 =item process_batch_import JOB, PARAMS
369
370 Starts a batch import given JOB (an L<FS::queue>) and PARAMS (a 
371 Base64-Storable hash).  PARAMS should contain 'format' and 'uploaded_files'.
372
373 Currently only usable for Billsoft imports; CCH's agglomeration of update
374 files need to be imported through L<FS::tax_rate::process_batch_import>.
375
376 =cut
377
378 sub process_batch_import {
379   my $job = shift;
380   my $param = shift;
381
382   my $files = $param->{'uploaded_files'};
383
384   my ($file) = ($files =~ /^zipfile:(.*)$/)
385     or die "No files provided.\n";
386
387   my $dir = $FS::UID::cache_dir . '/cache.' . $FS::UID::datasrc;
388   open ( $param->{'filehandle'}, '<', "$dir/$file" )
389     or die "unable to open '$file': $!\n";
390
391   my $error = batch_import($param, $job);
392   die $error if $error;
393 }
394
395 =back
396
397 =head1 QUIRKS
398
399 CCH doesn't have a "country" field; for addresses in Canada it uses state 
400 = 'CN', and zip = the one-letter postal code prefix for the province.  Or
401 maybe that's just our CCH implementation.  This doesn't apply to Billsoft,
402 and shouldn't apply to any other tax vendor that may somehow be implemented.
403
404 CCH also has two styles of records in this table: cch and cch-zip.  cch 
405 records define a unique 
406
407 =head1 BUGS
408
409 CCH clutter.
410
411 =head1 SEE ALSO
412
413 L<FS::Record>, schema.html from the base documentation.
414
415 =cut
416
417 1;
418