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