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