add billing address fields to RT ticket search, #19154
[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 incorporated
98
99 Incorporated city flag: set to 'Y' if the address is in the legal borders 
100 of an incorporated city.
101
102 =item disabled
103
104 Disabled flag; set to 'Y' to disable the location.
105
106 =back
107
108 =head1 METHODS
109
110 =over 4
111
112 =item new HASHREF
113
114 Creates a new location.  To add the location to the database, see L<"insert">.
115
116 Note that this stores the hash reference, not a distinct copy of the hash it
117 points to.  You can ask the object for a copy with the I<hash> method.
118
119 =cut
120
121 sub table { 'cust_location'; }
122
123 =item find_or_insert
124
125 Finds an existing location matching the customer and address values in this
126 location, if one exists, and sets the contents of this location equal to that
127 one (including its locationnum).
128
129 If an existing location is not found, this one I<will> be inserted.  (This is a
130 change from the "new_or_existing" method that this replaces.)
131
132 The following fields are considered "essential" and I<must> match: custnum,
133 address1, address2, city, county, state, zip, country, location_number,
134 location_type, location_kind.  Disabled locations will be found only if this
135 location is set to disabled.
136
137 All other fields are considered "non-essential" and will be ignored in 
138 finding a matching location.  If the existing location doesn't match 
139 in these fields, it will be updated in-place to match.
140
141 Returns an error string if inserting or updating a location failed.
142
143 It is unfortunately hard to determine if this created a new location or not.
144
145 =cut
146
147 sub find_or_insert {
148   my $self = shift;
149
150   warn "find_or_insert:\n".Dumper($self) if $DEBUG;
151
152   my @essential = (qw(custnum address1 address2 city county state zip country
153     location_number location_type location_kind disabled));
154
155   if ($conf->exists('cust_main-no_city_in_address')) {
156     warn "Warning: passed city to find_or_insert when cust_main-no_city_in_address is configured, ignoring it"
157       if $self->get('city');
158     $self->set('city','');
159   }
160
161   # I don't think this is necessary
162   #if ( !$self->coord_auto and $self->latitude and $self->longitude ) {
163   #  push @essential, qw(latitude longitude);
164   #  # but NOT coord_auto; if the latitude and longitude match the geocoded
165   #  # values then that's good enough
166   #}
167
168   # put nonempty, nonessential fields/values into this hash
169   my %nonempty = map { $_ => $self->get($_) }
170                  grep {$self->get($_)} $self->fields;
171   delete @nonempty{@essential};
172   delete $nonempty{'locationnum'};
173
174   my %hash = map { $_ => $self->get($_) } @essential;
175   my @matches = qsearch('cust_location', \%hash);
176
177   # we no longer reject matches for having different values in nonessential
178   # fields; we just alter the record to match
179   if ( @matches ) {
180     my $old = $matches[0];
181     warn "found existing location #".$old->locationnum."\n" if $DEBUG;
182     foreach my $field (keys %nonempty) {
183       if ($old->get($field) ne $nonempty{$field}) {
184         warn "altering $field to match requested location" if $DEBUG;
185         $old->set($field, $nonempty{$field});
186       }
187     } # foreach $field
188
189     if ( $old->modified ) {
190       warn "updating non-essential fields\n" if $DEBUG;
191       my $error = $old->replace;
192       return $error if $error;
193     }
194     # set $self equal to $old
195     foreach ($self->fields) {
196       $self->set($_, $old->get($_));
197     }
198     return "";
199   }
200
201   # didn't find a match
202   warn "not found; inserting new location\n" if $DEBUG;
203   return $self->insert;
204 }
205
206 =item insert
207
208 Adds this record to the database.  If there is an error, returns the error,
209 otherwise returns false.
210
211 =cut
212
213 sub insert {
214   my $self = shift;
215
216   if ($conf->exists('cust_main-no_city_in_address')) {
217     warn "Warning: passed city to insert when cust_main-no_city_in_address is configured, ignoring it"
218       if $self->get('city');
219     $self->set('city','');
220   }
221
222   if ( $self->censustract ) {
223     $self->set('censusyear' => $conf->config('census_year') || 2012);
224   }
225
226   my $oldAutoCommit = $FS::UID::AutoCommit;
227   local $FS::UID::AutoCommit = 0;
228   my $dbh = dbh;
229
230   my $error = $self->SUPER::insert(@_);
231   if ( $error ) {
232     $dbh->rollback if $oldAutoCommit;
233     return $error;
234   }
235
236   #false laziness with cust_main, will go away eventually
237   if ( !$import and $conf->config('tax_district_method') ) {
238
239     my $queue = new FS::queue {
240       'job' => 'FS::geocode_Mixin::process_district_update'
241     };
242     $error = $queue->insert( ref($self), $self->locationnum );
243     if ( $error ) {
244       $dbh->rollback if $oldAutoCommit;
245       return $error;
246     }
247
248   }
249
250   # cust_location exports
251   #my $export_args = $options{'export_args'} || [];
252
253   my @part_export =
254     map qsearch( 'part_export', {exportnum=>$_} ),
255       $conf->config('cust_location-exports'); #, $agentnum
256
257   foreach my $part_export ( @part_export ) {
258     my $error = $part_export->export_insert($self); #, @$export_args);
259     if ( $error ) {
260       $dbh->rollback if $oldAutoCommit;
261       return "exporting to ". $part_export->exporttype.
262              " (transaction rolled back): $error";
263     }
264   }
265
266
267   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
268   '';
269 }
270
271 =item delete
272
273 Delete this record from the database.
274
275 =item replace OLD_RECORD
276
277 Replaces the OLD_RECORD with this one in the database.  If there is an error,
278 returns the error, otherwise returns false.
279
280 =cut
281
282 sub replace {
283   my $self = shift;
284   my $old = shift;
285   $old ||= $self->replace_old;
286
287   warn "Warning: passed city to replace when cust_main-no_city_in_address is configured"
288     if $conf->exists('cust_main-no_city_in_address') && $self->get('city');
289
290   # the following fields are immutable
291   foreach (qw(address1 address2 city state zip country)) {
292     if ( $self->$_ ne $old->$_ ) {
293       return "can't change cust_location field $_";
294     }
295   }
296
297   my $oldAutoCommit = $FS::UID::AutoCommit;
298   local $FS::UID::AutoCommit = 0;
299   my $dbh = dbh;
300
301   my $error = $self->SUPER::replace($old);
302   if ( $error ) {
303     $dbh->rollback if $oldAutoCommit;
304     return $error;
305   }
306
307   # cust_location exports
308   #my $export_args = $options{'export_args'} || [];
309
310   my @part_export =
311     map qsearch( 'part_export', {exportnum=>$_} ),
312       $conf->config('cust_location-exports'); #, $agentnum
313
314   foreach my $part_export ( @part_export ) {
315     my $error = $part_export->export_replace($self, $old); #, @$export_args);
316     if ( $error ) {
317       $dbh->rollback if $oldAutoCommit;
318       return "exporting to ". $part_export->exporttype.
319              " (transaction rolled back): $error";
320     }
321   }
322
323
324   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
325   '';
326 }
327
328
329 =item check
330
331 Checks all fields to make sure this is a valid location.  If there is
332 an error, returns the error, otherwise returns false.  Called by the insert
333 and replace methods.
334
335 =cut
336
337 sub check {
338   my $self = shift;
339
340   return '' if $self->disabled; # so that disabling locations never fails
341
342   my $error = 
343     $self->ut_numbern('locationnum')
344     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
345     || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
346     || $self->ut_textn('locationname')
347     || $self->ut_text('address1')
348     || $self->ut_textn('address2')
349     || ($conf->exists('cust_main-no_city_in_address') 
350         ? $self->ut_textn('city') 
351         : $self->ut_text('city'))
352     || $self->ut_textn('county')
353     || $self->ut_textn('state')
354     || $self->ut_country('country')
355     || (!$import && $self->ut_zip('zip', $self->country))
356     || $self->ut_coordn('latitude')
357     || $self->ut_coordn('longitude')
358     || $self->ut_enum('coord_auto', [ '', 'Y' ])
359     || $self->ut_enum('addr_clean', [ '', 'Y' ])
360     || $self->ut_alphan('location_type')
361     || $self->ut_textn('location_number')
362     || $self->ut_enum('location_kind', [ '', 'R', 'B' ] )
363     || $self->ut_alphan('geocode')
364     || $self->ut_alphan('district')
365     || $self->ut_numbern('censusyear')
366     || $self->ut_flag('incorporated')
367   ;
368   return $error if $error;
369   if ( $self->censustract ne '' ) {
370     $self->censustract =~ /^\s*(\d{9})\.?(\d{2})\s*$/
371       or return "Illegal census tract: ". $self->censustract;
372
373     $self->censustract("$1.$2");
374   }
375
376   if ( $conf->exists('cust_main-require_address2') and 
377        !$self->ship_address2 =~ /\S/ ) {
378     return "Unit # is required";
379   }
380
381   # tricky...we have to allow for the customer to not be inserted yet
382   return "No prospect or customer!" unless $self->prospectnum 
383                                         || $self->custnum
384                                         || $self->get('custnum_pending');
385   return "Prospect and customer!"       if $self->prospectnum && $self->custnum;
386
387   return 'Location kind is required'
388     if $self->prospectnum
389     && $conf->exists('prospect_main-alt_address_format')
390     && ! $self->location_kind;
391
392   unless ( $import or qsearch('cust_main_county', {
393     'country' => $self->country,
394     'state'   => '',
395    } ) ) {
396     return "Unknown state/county/country: ".
397       $self->state. "/". $self->county. "/". $self->country
398       unless qsearch('cust_main_county',{
399         'state'   => $self->state,
400         'county'  => $self->county,
401         'country' => $self->country,
402       } );
403   }
404
405   # set coordinates, unless we already have them
406   if (!$import and !$self->latitude and !$self->longitude) {
407     $self->set_coord;
408   }
409
410   $self->SUPER::check;
411 }
412
413 =item country_full
414
415 Returns this locations's full country name
416
417 =cut
418
419 sub country_full {
420   my $self = shift;
421   code2country($self->country);
422 }
423
424 =item line
425
426 Synonym for location_label
427
428 =cut
429
430 sub line {
431   my $self = shift;
432   $self->location_label(@_);
433 }
434
435 =item has_ship_address
436
437 Returns false since cust_location objects do not have a separate shipping
438 address.
439
440 =cut
441
442 sub has_ship_address {
443   '';
444 }
445
446 =item location_hash
447
448 Returns a list of key/value pairs, with the following keys: address1, address2,
449 city, county, state, zip, country, geocode, location_type, location_number,
450 location_kind.
451
452 =cut
453
454 =item disable_if_unused
455
456 Sets the "disabled" flag on the location if it is no longer in use as a 
457 prospect location, package location, or a customer's billing or default
458 service address.
459
460 =cut
461
462 sub disable_if_unused {
463
464   my $self = shift;
465   my $locationnum = $self->locationnum;
466   return '' if FS::cust_main->count('bill_locationnum = '.$locationnum.' OR
467                                      ship_locationnum = '.$locationnum)
468             or FS::contact->count(      'locationnum  = '.$locationnum)
469             or FS::cust_pkg->count('cancel IS NULL AND 
470                                          locationnum  = '.$locationnum)
471           ;
472   $self->disabled('Y');
473   $self->replace;
474
475 }
476
477 =item move_to
478
479 Takes a new L<FS::cust_location> object.  Moves all packages that use the 
480 existing location to the new one, then sets the "disabled" flag on the old
481 location.  Returns nothing on success, an error message on error.
482
483 =cut
484
485 sub move_to {
486   my $old = shift;
487   my $new = shift;
488   
489   warn "move_to:\nFROM:".Dumper($old)."\nTO:".Dumper($new) if $DEBUG;
490
491   local $SIG{HUP} = 'IGNORE';
492   local $SIG{INT} = 'IGNORE';
493   local $SIG{QUIT} = 'IGNORE';
494   local $SIG{TERM} = 'IGNORE';
495   local $SIG{TSTP} = 'IGNORE';
496   local $SIG{PIPE} = 'IGNORE';
497
498   my $oldAutoCommit = $FS::UID::AutoCommit;
499   local $FS::UID::AutoCommit = 0;
500   my $dbh = dbh;
501   my $error = '';
502
503   # prevent this from failing because of pkg_svc quantity limits
504   local( $FS::cust_svc::ignore_quantity ) = 1;
505
506   if ( !$new->locationnum ) {
507     $error = $new->insert;
508     if ( $error ) {
509       $dbh->rollback if $oldAutoCommit;
510       return "Error creating location: $error";
511     }
512   } elsif ( $new->locationnum == $old->locationnum ) {
513     # then they're the same location; the normal result of doing a minor
514     # location edit
515     $dbh->commit if $oldAutoCommit;
516     return '';
517   }
518
519   # find all packages that have the old location as their service address,
520   # and aren't canceled,
521   # and aren't supplemental to another package.
522   my @pkgs = qsearch('cust_pkg', { 
523       'locationnum' => $old->locationnum,
524       'cancel'      => '',
525       'main_pkgnum' => '',
526     });
527   foreach my $cust_pkg (@pkgs) {
528     # don't move one-time charges that have already been charged
529     next if $cust_pkg->part_pkg->freq eq '0'
530             and ($cust_pkg->setup || 0) > 0;
531
532     $error = $cust_pkg->change(
533       'locationnum' => $new->locationnum,
534       'keep_dates'  => 1
535     );
536     if ( $error and not ref($error) ) {
537       $dbh->rollback if $oldAutoCommit;
538       return "Error moving pkgnum ".$cust_pkg->pkgnum.": $error";
539     }
540   }
541
542   $error = $old->disable_if_unused;
543   if ( $error ) {
544     $dbh->rollback if $oldAutoCommit;
545     return "Error disabling old location: $error";
546   }
547
548   $dbh->commit if $oldAutoCommit;
549   '';
550 }
551
552 =item alternize
553
554 Attempts to parse data for location_type and location_number from address1
555 and address2.
556
557 =cut
558
559 sub alternize {
560   my $self = shift;
561
562   return '' if $self->get('location_type')
563             || $self->get('location_number');
564
565   my %parse;
566   if ( 1 ) { #ikano, switch on via config
567     { no warnings 'void';
568       eval { 'use FS::part_export::ikano;' };
569       die $@ if $@;
570     }
571     %parse = FS::part_export::ikano->location_types_parse;
572   } else {
573     %parse = (); #?
574   }
575
576   foreach my $from ('address1', 'address2') {
577     foreach my $parse ( keys %parse ) {
578       my $value = $self->get($from);
579       if ( $value =~ s/(^|\W+)$parse\W+(\w+)\W*$//i ) {
580         $self->set('location_type', $parse{$parse});
581         $self->set('location_number', $2);
582         $self->set($from, $value);
583         return '';
584       }
585     }
586   }
587
588   #nothing matched, no changes
589   $self->get('address2')
590     ? "Can't parse unit type and number from address2"
591     : '';
592 }
593
594 =item dealternize
595
596 Moves data from location_type and location_number to the end of address1.
597
598 =cut
599
600 sub dealternize {
601   my $self = shift;
602
603   #false laziness w/geocode_Mixin.pm::line
604   my $lt = $self->get('location_type');
605   if ( $lt ) {
606
607     my %location_type;
608     if ( 1 ) { #ikano, switch on via config
609       { no warnings 'void';
610         eval { 'use FS::part_export::ikano;' };
611         die $@ if $@;
612       }
613       %location_type = FS::part_export::ikano->location_types;
614     } else {
615       %location_type = (); #?
616     }
617
618     $self->address1( $self->address1. ' '. $location_type{$lt} || $lt );
619     $self->location_type('');
620   }
621
622   if ( length($self->location_number) ) {
623     $self->address1( $self->address1. ' '. $self->location_number );
624     $self->location_number('');
625   }
626  
627   '';
628 }
629
630 =item location_label
631
632 Returns the label of the location object.
633
634 Options:
635
636 =over 4
637
638 =item cust_main
639
640 Customer object (see L<FS::cust_main>)
641
642 =item prospect_main
643
644 Prospect object (see L<FS::prospect_main>)
645
646 =item join_string
647
648 String used to join location elements
649
650 =item no_prefix
651
652 Don't label the default service location as "Default service location".
653 May become the default at some point.
654
655 =back
656
657 =cut
658
659 sub location_label {
660   my( $self, %opt ) = @_;
661
662   my $prefix = $self->label_prefix;
663   $prefix .= ($opt{join_string} ||  ': ') if $prefix;
664   $prefix = '' if $opt{'no_prefix'};
665
666   $prefix . $self->SUPER::location_label(%opt);
667 }
668
669 =item label_prefix
670
671 Returns the optional site ID string (based on the cust_location-label_prefix
672 config option), "Default service location", or the empty string.
673
674 Options:
675
676 =over 4
677
678 =item cust_main
679
680 Customer object (see L<FS::cust_main>)
681
682 =item prospect_main
683
684 Prospect object (see L<FS::prospect_main>)
685
686 =back
687
688 =cut
689
690 sub label_prefix {
691   my( $self, %opt ) = @_;
692
693   my $cust_or_prospect = $opt{cust_main} || $opt{prospect_main};
694   unless ( $cust_or_prospect ) {
695     if ( $self->custnum ) {
696       $cust_or_prospect = FS::cust_main->by_key($self->custnum);
697     } elsif ( $self->prospectnum ) {
698       $cust_or_prospect = FS::prospect_main->by_key($self->prospectnum);
699     }
700   }
701
702   my $prefix = '';
703   if ( $label_prefix eq 'CoStAg' ) {
704     my $agent = $conf->config('cust_main-custnum-display_prefix',
705                   $cust_or_prospect->agentnum)
706                 || $cust_or_prospect->agent->agent;
707     # else this location is invalid
708     $prefix = uc( join('',
709         $self->country,
710         ($self->state =~ /^(..)/),
711         ($agent =~ /^(..)/),
712         sprintf('%05d', $self->locationnum)
713     ) );
714
715   } elsif ( $label_prefix eq '_location' && $self->locationname ) {
716     $prefix = $self->locationname;
717
718   } elsif (    ( $opt{'cust_main'} || $self->custnum )
719           && $self->locationnum == $cust_or_prospect->ship_locationnum ) {
720     $prefix = 'Default service location';
721   }
722
723   $prefix;
724 }
725
726 =item county_state_county
727
728 Returns a string consisting of just the county, state and country.
729
730 =cut
731
732 sub county_state_country {
733   my $self = shift;
734   my $label = $self->country;
735   $label = $self->state.", $label" if $self->state;
736   $label = $self->county." County, $label" if $self->county;
737   $label;
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