allow cust_http export to be triggered for location changes, RT#22830
[freeside.git] / FS / FS / cust_location.pm
1 package FS::cust_location;
2 use base qw( FS::geocode_Mixin FS::Record );
3
4 use strict;
5 use vars qw( $import $DEBUG $conf $label_prefix );
6 use Data::Dumper;
7 use Date::Format qw( time2str );
8 use Locale::Country;
9 use FS::UID qw( dbh driver_name );
10 use FS::Record qw( qsearch qsearchs );
11 use FS::Conf;
12 use FS::prospect_main;
13 use FS::cust_main;
14 use FS::cust_main_county;
15 use FS::part_export;
16 use FS::GeocodeCache;
17
18 $import = 0;
19
20 $DEBUG = 0;
21
22 FS::UID->install_callback( sub {
23   $conf = FS::Conf->new;
24   $label_prefix = $conf->config('cust_location-label_prefix') || '';
25 });
26
27 =head1 NAME
28
29 FS::cust_location - Object methods for cust_location records
30
31 =head1 SYNOPSIS
32
33   use FS::cust_location;
34
35   $record = new FS::cust_location \%hash;
36   $record = new FS::cust_location { 'column' => 'value' };
37
38   $error = $record->insert;
39
40   $error = $new_record->replace($old_record);
41
42   $error = $record->delete;
43
44   $error = $record->check;
45
46 =head1 DESCRIPTION
47
48 An FS::cust_location object represents a customer location.  FS::cust_location
49 inherits from FS::Record.  The following fields are currently supported:
50
51 =over 4
52
53 =item locationnum
54
55 primary key
56
57 =item custnum
58
59 custnum
60
61 =item address1
62
63 Address line one (required)
64
65 =item address2
66
67 Address line two (optional)
68
69 =item city
70
71 City
72
73 =item county
74
75 County (optional, see L<FS::cust_main_county>)
76
77 =item state
78
79 State (see L<FS::cust_main_county>)
80
81 =item zip
82
83 Zip
84
85 =item country
86
87 Country (see L<FS::cust_main_county>)
88
89 =item geocode
90
91 Geocode
92
93 =item district
94
95 Tax district code (optional)
96
97 =item disabled
98
99 Disabled flag; set to 'Y' to disable the location.
100
101 =back
102
103 =head1 METHODS
104
105 =over 4
106
107 =item new HASHREF
108
109 Creates a new location.  To add the location to the database, see L<"insert">.
110
111 Note that this stores the hash reference, not a distinct copy of the hash it
112 points to.  You can ask the object for a copy with the I<hash> method.
113
114 =cut
115
116 sub table { 'cust_location'; }
117
118 =item find_or_insert
119
120 Finds an existing location matching the customer and address values in this
121 location, if one exists, and sets the contents of this location equal to that
122 one (including its locationnum).
123
124 If an existing location is not found, this one I<will> be inserted.  (This is a
125 change from the "new_or_existing" method that this replaces.)
126
127 The following fields are considered "essential" and I<must> match: custnum,
128 address1, address2, city, county, state, zip, country, location_number,
129 location_type, location_kind.  Disabled locations will be found only if this
130 location is set to disabled.
131
132 All other fields are considered "non-essential" and will be ignored in 
133 finding a matching location.  If the existing location doesn't match 
134 in these fields, it will be updated in-place to match.
135
136 Returns an error string if inserting or updating a location failed.
137
138 It is unfortunately hard to determine if this created a new location or not.
139
140 =cut
141
142 sub find_or_insert {
143   my $self = shift;
144
145   warn "find_or_insert:\n".Dumper($self) if $DEBUG;
146
147   my @essential = (qw(custnum address1 address2 city county state zip country
148     location_number location_type location_kind disabled));
149
150   # I don't think this is necessary
151   #if ( !$self->coord_auto and $self->latitude and $self->longitude ) {
152   #  push @essential, qw(latitude longitude);
153   #  # but NOT coord_auto; if the latitude and longitude match the geocoded
154   #  # values then that's good enough
155   #}
156
157   # put nonempty, nonessential fields/values into this hash
158   my %nonempty = map { $_ => $self->get($_) }
159                  grep {$self->get($_)} $self->fields;
160   delete @nonempty{@essential};
161   delete $nonempty{'locationnum'};
162
163   my %hash = map { $_ => $self->get($_) } @essential;
164   my @matches = qsearch('cust_location', \%hash);
165
166   # we no longer reject matches for having different values in nonessential
167   # fields; we just alter the record to match
168   if ( @matches ) {
169     my $old = $matches[0];
170     warn "found existing location #".$old->locationnum."\n" if $DEBUG;
171     foreach my $field (keys %nonempty) {
172       if ($old->get($field) ne $nonempty{$field}) {
173         warn "altering $field to match requested location" if $DEBUG;
174         $old->set($field, $nonempty{$field});
175       }
176     } # foreach $field
177
178     if ( $old->modified ) {
179       warn "updating non-essential fields\n" if $DEBUG;
180       my $error = $old->replace;
181       return $error if $error;
182     }
183     # set $self equal to $old
184     foreach ($self->fields) {
185       $self->set($_, $old->get($_));
186     }
187     return "";
188   }
189
190   # didn't find a match
191   warn "not found; inserting new location\n" if $DEBUG;
192   return $self->insert;
193 }
194
195 =item insert
196
197 Adds this record to the database.  If there is an error, returns the error,
198 otherwise returns false.
199
200 =cut
201
202 sub insert {
203   my $self = shift;
204
205   if ( $self->censustract ) {
206     $self->set('censusyear' => $conf->config('census_year') || 2012);
207   }
208
209   my $oldAutoCommit = $FS::UID::AutoCommit;
210   local $FS::UID::AutoCommit = 0;
211   my $dbh = dbh;
212
213   my $error = $self->SUPER::insert(@_);
214   if ( $error ) {
215     $dbh->rollback if $oldAutoCommit;
216     return $error;
217   }
218
219   #false laziness with cust_main, will go away eventually
220   if ( !$import and $conf->config('tax_district_method') ) {
221
222     my $queue = new FS::queue {
223       'job' => 'FS::geocode_Mixin::process_district_update'
224     };
225     $error = $queue->insert( ref($self), $self->locationnum );
226     if ( $error ) {
227       $dbh->rollback if $oldAutoCommit;
228       return $error;
229     }
230
231   }
232
233   # cust_location exports
234   #my $export_args = $options{'export_args'} || [];
235
236   my @part_export =
237     map qsearch( 'part_export', {exportnum=>$_} ),
238       $conf->config('cust_location-exports'); #, $agentnum
239
240   foreach my $part_export ( @part_export ) {
241     my $error = $part_export->export_insert($self); #, @$export_args);
242     if ( $error ) {
243       $dbh->rollback if $oldAutoCommit;
244       return "exporting to ". $part_export->exporttype.
245              " (transaction rolled back): $error";
246     }
247   }
248
249
250   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
251   '';
252 }
253
254 =item delete
255
256 Delete this record from the database.
257
258 =item replace OLD_RECORD
259
260 Replaces the OLD_RECORD with this one in the database.  If there is an error,
261 returns the error, otherwise returns false.
262
263 =cut
264
265 sub replace {
266   my $self = shift;
267   my $old = shift;
268   $old ||= $self->replace_old;
269   # the following fields are immutable
270   foreach (qw(address1 address2 city state zip country)) {
271     if ( $self->$_ ne $old->$_ ) {
272       return "can't change cust_location field $_";
273     }
274   }
275
276   my $oldAutoCommit = $FS::UID::AutoCommit;
277   local $FS::UID::AutoCommit = 0;
278   my $dbh = dbh;
279
280   my $error = $self->SUPER::replace($old);
281   if ( $error ) {
282     $dbh->rollback if $oldAutoCommit;
283     return $error;
284   }
285
286   # cust_location exports
287   #my $export_args = $options{'export_args'} || [];
288
289   my @part_export =
290     map qsearch( 'part_export', {exportnum=>$_} ),
291       $conf->config('cust_location-exports'); #, $agentnum
292
293   foreach my $part_export ( @part_export ) {
294     my $error = $part_export->export_replace($self, $old); #, @$export_args);
295     if ( $error ) {
296       $dbh->rollback if $oldAutoCommit;
297       return "exporting to ". $part_export->exporttype.
298              " (transaction rolled back): $error";
299     }
300   }
301
302
303   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
304   '';
305 }
306
307
308 =item check
309
310 Checks all fields to make sure this is a valid location.  If there is
311 an error, returns the error, otherwise returns false.  Called by the insert
312 and replace methods.
313
314 =cut
315
316 sub check {
317   my $self = shift;
318
319   return '' if $self->disabled; # so that disabling locations never fails
320
321   my $error = 
322     $self->ut_numbern('locationnum')
323     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
324     || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
325     || $self->ut_text('address1')
326     || $self->ut_textn('address2')
327     || $self->ut_text('city')
328     || $self->ut_textn('county')
329     || $self->ut_textn('state')
330     || $self->ut_country('country')
331     || (!$import && $self->ut_zip('zip', $self->country))
332     || $self->ut_coordn('latitude')
333     || $self->ut_coordn('longitude')
334     || $self->ut_enum('coord_auto', [ '', 'Y' ])
335     || $self->ut_enum('addr_clean', [ '', 'Y' ])
336     || $self->ut_alphan('location_type')
337     || $self->ut_textn('location_number')
338     || $self->ut_enum('location_kind', [ '', 'R', 'B' ] )
339     || $self->ut_alphan('geocode')
340     || $self->ut_alphan('district')
341     || $self->ut_numbern('censusyear')
342   ;
343   return $error if $error;
344   if ( $self->censustract ne '' ) {
345     $self->censustract =~ /^\s*(\d{9})\.?(\d{2})\s*$/
346       or return "Illegal census tract: ". $self->censustract;
347
348     $self->censustract("$1.$2");
349   }
350
351   if ( $conf->exists('cust_main-require_address2') and 
352        !$self->ship_address2 =~ /\S/ ) {
353     return "Unit # is required";
354   }
355
356   # tricky...we have to allow for the customer to not be inserted yet
357   return "No prospect or customer!" unless $self->prospectnum 
358                                         || $self->custnum
359                                         || $self->get('custnum_pending');
360   return "Prospect and customer!"       if $self->prospectnum && $self->custnum;
361
362   return 'Location kind is required'
363     if $self->prospectnum
364     && $conf->exists('prospect_main-alt_address_format')
365     && ! $self->location_kind;
366
367   unless ( $import or qsearch('cust_main_county', {
368     'country' => $self->country,
369     'state'   => '',
370    } ) ) {
371     return "Unknown state/county/country: ".
372       $self->state. "/". $self->county. "/". $self->country
373       unless qsearch('cust_main_county',{
374         'state'   => $self->state,
375         'county'  => $self->county,
376         'country' => $self->country,
377       } );
378   }
379
380   # set coordinates, unless we already have them
381   if (!$import and !$self->latitude and !$self->longitude) {
382     $self->set_coord;
383   }
384
385   $self->SUPER::check;
386 }
387
388 =item country_full
389
390 Returns this locations's full country name
391
392 =cut
393
394 sub country_full {
395   my $self = shift;
396   code2country($self->country);
397 }
398
399 =item line
400
401 Synonym for location_label
402
403 =cut
404
405 sub line {
406   my $self = shift;
407   $self->location_label(@_);
408 }
409
410 =item has_ship_address
411
412 Returns false since cust_location objects do not have a separate shipping
413 address.
414
415 =cut
416
417 sub has_ship_address {
418   '';
419 }
420
421 =item location_hash
422
423 Returns a list of key/value pairs, with the following keys: address1, address2,
424 city, county, state, zip, country, geocode, location_type, location_number,
425 location_kind.
426
427 =cut
428
429 =item disable_if_unused
430
431 Sets the "disabled" flag on the location if it is no longer in use as a 
432 prospect location, package location, or a customer's billing or default
433 service address.
434
435 =cut
436
437 sub disable_if_unused {
438
439   my $self = shift;
440   my $locationnum = $self->locationnum;
441   return '' if FS::cust_main->count('bill_locationnum = '.$locationnum)
442             or FS::cust_main->count('ship_locationnum = '.$locationnum)
443             or FS::contact->count(      'locationnum  = '.$locationnum)
444             or FS::cust_pkg->count('cancel IS NULL AND 
445                                          locationnum  = '.$locationnum)
446           ;
447   $self->disabled('Y');
448   $self->replace;
449
450 }
451
452 =item move_to
453
454 Takes a new L<FS::cust_location> object.  Moves all packages that use the 
455 existing location to the new one, then sets the "disabled" flag on the old
456 location.  Returns nothing on success, an error message on error.
457
458 =cut
459
460 sub move_to {
461   my $old = shift;
462   my $new = shift;
463   
464   warn "move_to:\nFROM:".Dumper($old)."\nTO:".Dumper($new) if $DEBUG;
465
466   local $SIG{HUP} = 'IGNORE';
467   local $SIG{INT} = 'IGNORE';
468   local $SIG{QUIT} = 'IGNORE';
469   local $SIG{TERM} = 'IGNORE';
470   local $SIG{TSTP} = 'IGNORE';
471   local $SIG{PIPE} = 'IGNORE';
472
473   my $oldAutoCommit = $FS::UID::AutoCommit;
474   local $FS::UID::AutoCommit = 0;
475   my $dbh = dbh;
476   my $error = '';
477
478   # prevent this from failing because of pkg_svc quantity limits
479   local( $FS::cust_svc::ignore_quantity ) = 1;
480
481   if ( !$new->locationnum ) {
482     $error = $new->insert;
483     if ( $error ) {
484       $dbh->rollback if $oldAutoCommit;
485       return "Error creating location: $error";
486     }
487   } elsif ( $new->locationnum == $old->locationnum ) {
488     # then they're the same location; the normal result of doing a minor
489     # location edit
490     $dbh->commit if $oldAutoCommit;
491     return '';
492   }
493
494   # find all packages that have the old location as their service address,
495   # and aren't canceled,
496   # and aren't supplemental to another package.
497   my @pkgs = qsearch('cust_pkg', { 
498       'locationnum' => $old->locationnum,
499       'cancel'      => '',
500       'main_pkgnum' => '',
501     });
502   foreach my $cust_pkg (@pkgs) {
503     # don't move one-time charges that have already been charged
504     next if $cust_pkg->part_pkg->freq eq '0'
505             and ($cust_pkg->setup || 0) > 0;
506
507     $error = $cust_pkg->change(
508       'locationnum' => $new->locationnum,
509       'keep_dates'  => 1
510     );
511     if ( $error and not ref($error) ) {
512       $dbh->rollback if $oldAutoCommit;
513       return "Error moving pkgnum ".$cust_pkg->pkgnum.": $error";
514     }
515   }
516
517   $error = $old->disable_if_unused;
518   if ( $error ) {
519     $dbh->rollback if $oldAutoCommit;
520     return "Error disabling old location: $error";
521   }
522
523   $dbh->commit if $oldAutoCommit;
524   '';
525 }
526
527 =item alternize
528
529 Attempts to parse data for location_type and location_number from address1
530 and address2.
531
532 =cut
533
534 sub alternize {
535   my $self = shift;
536
537   return '' if $self->get('location_type')
538             || $self->get('location_number');
539
540   my %parse;
541   if ( 1 ) { #ikano, switch on via config
542     { no warnings 'void';
543       eval { 'use FS::part_export::ikano;' };
544       die $@ if $@;
545     }
546     %parse = FS::part_export::ikano->location_types_parse;
547   } else {
548     %parse = (); #?
549   }
550
551   foreach my $from ('address1', 'address2') {
552     foreach my $parse ( keys %parse ) {
553       my $value = $self->get($from);
554       if ( $value =~ s/(^|\W+)$parse\W+(\w+)\W*$//i ) {
555         $self->set('location_type', $parse{$parse});
556         $self->set('location_number', $2);
557         $self->set($from, $value);
558         return '';
559       }
560     }
561   }
562
563   #nothing matched, no changes
564   $self->get('address2')
565     ? "Can't parse unit type and number from address2"
566     : '';
567 }
568
569 =item dealternize
570
571 Moves data from location_type and location_number to the end of address1.
572
573 =cut
574
575 sub dealternize {
576   my $self = shift;
577
578   #false laziness w/geocode_Mixin.pm::line
579   my $lt = $self->get('location_type');
580   if ( $lt ) {
581
582     my %location_type;
583     if ( 1 ) { #ikano, switch on via config
584       { no warnings 'void';
585         eval { 'use FS::part_export::ikano;' };
586         die $@ if $@;
587       }
588       %location_type = FS::part_export::ikano->location_types;
589     } else {
590       %location_type = (); #?
591     }
592
593     $self->address1( $self->address1. ' '. $location_type{$lt} || $lt );
594     $self->location_type('');
595   }
596
597   if ( length($self->location_number) ) {
598     $self->address1( $self->address1. ' '. $self->location_number );
599     $self->location_number('');
600   }
601  
602   '';
603 }
604
605 =item location_label
606
607 Returns the label of the location object, with an optional site ID
608 string (based on the cust_location-label_prefix config option).
609
610 =cut
611
612 sub location_label {
613   my( $self, %opt ) = @_;
614
615   my $cust_or_prospect = $opt{cust_main} || $opt{prospect_main};
616   unless ( $cust_or_prospect ) {
617     if ( $self->custnum ) {
618       $cust_or_prospect = FS::cust_main->by_key($self->custnum);
619     } elsif ( $self->prospectnum ) {
620       $cust_or_prospect = FS::prospect_main->by_key($self->prospectnum);
621     }
622   }
623
624   my $prefix = '';
625   if ( $label_prefix eq 'CoStAg' ) {
626     my $agent = $conf->config('cust_main-custnum-display_prefix',
627                   $cust_or_prospect->agentnum)
628                 || $cust_or_prospect->agent->agent;
629     # else this location is invalid
630     $prefix = uc( join('',
631         $self->country,
632         ($self->state =~ /^(..)/),
633         ($agent =~ /^(..)/),
634         sprintf('%05d', $self->locationnum)
635     ) );
636   }
637   elsif (    ( $opt{'cust_main'} || $self->custnum )
638           && $self->locationnum == $cust_or_prospect->ship_locationnum ) {
639     $prefix = 'Default service location';
640   }
641
642   $prefix .= ($opt{join_string} ||  ': ') if $prefix;
643   $prefix . $self->SUPER::location_label(%opt);
644 }
645
646 =item county_state_county
647
648 Returns a string consisting of just the county, state and country.
649
650 =cut
651
652 sub county_state_country {
653   my $self = shift;
654   my $label = $self->country;
655   $label = $self->state.", $label" if $self->state;
656   $label = $self->county." County, $label" if $self->county;
657   $label;
658 }
659
660 =back
661
662 =head1 CLASS METHODS
663
664 =item in_county_sql OPTIONS
665
666 Returns an SQL expression to test membership in a cust_main_county 
667 geographic area.  By default, this requires district, city, county,
668 state, and country to match exactly.  Pass "ornull => 1" to allow 
669 partial matches where some fields are NULL in the cust_main_county 
670 record but not in the location.
671
672 Pass "param => 1" to receive a parameterized expression (rather than
673 one that requires a join to cust_main_county) and a list of parameter
674 names in order.
675
676 =cut
677
678 sub in_county_sql {
679   # replaces FS::cust_pkg::location_sql
680   my ($class, %opt) = @_;
681   my $ornull = $opt{ornull} ? ' OR ? IS NULL' : '';
682   my $x = $ornull ? 3 : 2;
683   my @fields = (('district') x 3,
684                 ('city') x 3,
685                 ('county') x $x,
686                 ('state') x $x,
687                 'country');
688
689   my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
690
691   my @where = (
692     "cust_location.district = ? OR ? = '' OR CAST(? AS $text) IS NULL",
693     "cust_location.city     = ? OR ? = '' OR CAST(? AS $text) IS NULL",
694     "cust_location.county   = ? OR (? = '' AND cust_location.county IS NULL) $ornull",
695     "cust_location.state    = ? OR (? = '' AND cust_location.state IS NULL ) $ornull",
696     "cust_location.country = ?"
697   );
698   my $sql = join(' AND ', map "($_)\n", @where);
699   if ( $opt{param} ) {
700     return $sql, @fields;
701   }
702   else {
703     # do the substitution here
704     foreach (@fields) {
705       $sql =~ s/\?/cust_main_county.$_/;
706       $sql =~ s/cust_main_county.$_ = ''/cust_main_county.$_ IS NULL/;
707     }
708     return $sql;
709   }
710 }
711
712 =back
713
714 =head2 SUBROUTINES
715
716 =over 4
717
718 =item process_censustract_update LOCATIONNUM
719
720 Queueable function to update the census tract to the current year (as set in 
721 the 'census_year' configuration variable) and retrieve the new tract code.
722
723 =cut
724
725 sub process_censustract_update {
726   eval "use FS::GeocodeCache";
727   die $@ if $@;
728   my $locationnum = shift;
729   my $cust_location = 
730     qsearchs( 'cust_location', { locationnum => $locationnum })
731       or die "locationnum '$locationnum' not found!\n";
732
733   my $new_year = $conf->config('census_year') or return;
734   my $loc = FS::GeocodeCache->new( $cust_location->location_hash );
735   $loc->set_censustract;
736   my $error = $loc->get('censustract_error');
737   die $error if $error;
738   $cust_location->set('censustract', $loc->get('censustract'));
739   $cust_location->set('censusyear',  $new_year);
740   $error = $cust_location->replace;
741   die $error if $error;
742   return;
743 }
744
745 =item process_set_coord
746
747 Queueable function to find and fill in coordinates for all locations that 
748 lack them.  Because this uses the Google Maps API, it's internally rate
749 limited and must run in a single process.
750
751 =cut
752
753 sub process_set_coord {
754   my $job = shift;
755   # avoid starting multiple instances of this job
756   my @others = qsearch('queue', {
757       'status'  => 'locked',
758       'job'     => $job->job,
759       'jobnum'  => {op=>'!=', value=>$job->jobnum},
760   });
761   return if @others;
762
763   $job->update_statustext('finding locations to update');
764   my @missing_coords = qsearch('cust_location', {
765       'disabled'  => '',
766       'latitude'  => '',
767       'longitude' => '',
768   });
769   my $i = 0;
770   my $n = scalar @missing_coords;
771   for my $cust_location (@missing_coords) {
772     $cust_location->set_coord;
773     my $error = $cust_location->replace;
774     if ( $error ) {
775       warn "error geocoding location#".$cust_location->locationnum.": $error\n";
776     } else {
777       $i++;
778       $job->update_statustext("updated $i / $n locations");
779       dbh->commit; # so that we don't have to wait for the whole thing to finish
780       # Rate-limit to stay under the Google Maps usage limit (2500/day).
781       # 86,400 / 35 = 2,468 lookups per day.
782     }
783     sleep 35;
784   }
785   if ( $i < $n ) {
786     die "failed to update ".$n-$i." locations\n";
787   }
788   return;
789 }
790
791 =item process_standardize [ LOCATIONNUMS ]
792
793 Performs address standardization on locations with unclean addresses,
794 using whatever method you have configured.  If the standardize_* method 
795 returns a I<clean> address match, the location will be updated.  This is 
796 always an in-place update (because the physical location is the same, 
797 and is just being referred to by a more accurate name).
798
799 Disabled locations will be skipped, as nobody cares.
800
801 If any LOCATIONNUMS are provided, only those locations will be updated.
802
803 =cut
804
805 sub process_standardize {
806   my $job = shift;
807   my @others = qsearch('queue', {
808       'status'  => 'locked',
809       'job'     => $job->job,
810       'jobnum'  => {op=>'!=', value=>$job->jobnum},
811   });
812   return if @others;
813   my @locationnums = grep /^\d+$/, @_;
814   my $where = "AND locationnum IN(".join(',',@locationnums).")"
815     if scalar(@locationnums);
816   my @locations = qsearch({
817       table     => 'cust_location',
818       hashref   => { addr_clean => '', disabled => '' },
819       extra_sql => $where,
820   });
821   my $n_todo = scalar(@locations);
822   my $n_done = 0;
823
824   # special: log this
825   my $log;
826   eval "use Text::CSV";
827   open $log, '>', "$FS::UID::cache_dir/process_standardize-" . 
828                   time2str('%Y%m%d',time) .
829                   ".csv";
830   my $csv = Text::CSV->new({binary => 1, eol => "\n"});
831
832   foreach my $cust_location (@locations) {
833     $job->update_statustext( int(100 * $n_done/$n_todo) . ",$n_done / $n_todo locations" ) if $job;
834     my $result = FS::GeocodeCache->standardize($cust_location);
835     if ( $result->{addr_clean} and !$result->{error} ) {
836       my @cols = ($cust_location->locationnum);
837       foreach (keys %$result) {
838         push @cols, $cust_location->get($_), $result->{$_};
839         $cust_location->set($_, $result->{$_});
840       }
841       # bypass immutable field restrictions
842       my $error = $cust_location->FS::Record::replace;
843       warn "location ".$cust_location->locationnum.": $error\n" if $error;
844       $csv->print($log, \@cols);
845     }
846     $n_done++;
847     dbh->commit; # so that we can resume if interrupted
848   }
849   close $log;
850 }
851
852 =head1 BUGS
853
854 =head1 SEE ALSO
855
856 L<FS::cust_main_county>, L<FS::cust_pkg>, L<FS::Record>,
857 schema.html from the base documentation.
858
859 =cut
860
861 1;
862