Merge branch 'master' of git.freeside.biz:/home/git/freeside
[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 =back
651
652 =cut
653
654 sub location_label {
655   my( $self, %opt ) = @_;
656
657   my $prefix = $self->label_prefix;
658   $prefix .= ($opt{join_string} ||  ': ') if $prefix;
659
660   $prefix . $self->SUPER::location_label(%opt);
661 }
662
663 =item label_prefix
664
665 Returns the optional site ID string (based on the cust_location-label_prefix
666 config option), "Default service location", or the empty string.
667
668 Options:
669
670 =over 4
671
672 =item cust_main
673
674 Customer object (see L<FS::cust_main>)
675
676 =item prospect_main
677
678 Prospect object (see L<FS::prospect_main>)
679
680 =back
681
682 =cut
683
684 sub label_prefix {
685   my( $self, %opt ) = @_;
686
687   my $cust_or_prospect = $opt{cust_main} || $opt{prospect_main};
688   unless ( $cust_or_prospect ) {
689     if ( $self->custnum ) {
690       $cust_or_prospect = FS::cust_main->by_key($self->custnum);
691     } elsif ( $self->prospectnum ) {
692       $cust_or_prospect = FS::prospect_main->by_key($self->prospectnum);
693     }
694   }
695
696   my $prefix = '';
697   if ( $label_prefix eq 'CoStAg' ) {
698     my $agent = $conf->config('cust_main-custnum-display_prefix',
699                   $cust_or_prospect->agentnum)
700                 || $cust_or_prospect->agent->agent;
701     # else this location is invalid
702     $prefix = uc( join('',
703         $self->country,
704         ($self->state =~ /^(..)/),
705         ($agent =~ /^(..)/),
706         sprintf('%05d', $self->locationnum)
707     ) );
708
709   } elsif ( $label_prefix eq '_location' && $self->locationname ) {
710     $prefix = $self->locationname;
711
712   } elsif (    ( $opt{'cust_main'} || $self->custnum )
713           && $self->locationnum == $cust_or_prospect->ship_locationnum ) {
714     $prefix = 'Default service location';
715   }
716
717   $prefix;
718 }
719
720 =item county_state_county
721
722 Returns a string consisting of just the county, state and country.
723
724 =cut
725
726 sub county_state_country {
727   my $self = shift;
728   my $label = $self->country;
729   $label = $self->state.", $label" if $self->state;
730   $label = $self->county." County, $label" if $self->county;
731   $label;
732 }
733
734 =back
735
736 =head2 SUBROUTINES
737
738 =over 4
739
740 =item process_censustract_update LOCATIONNUM
741
742 Queueable function to update the census tract to the current year (as set in 
743 the 'census_year' configuration variable) and retrieve the new tract code.
744
745 =cut
746
747 sub process_censustract_update {
748   eval "use FS::GeocodeCache";
749   die $@ if $@;
750   my $locationnum = shift;
751   my $cust_location = 
752     qsearchs( 'cust_location', { locationnum => $locationnum })
753       or die "locationnum '$locationnum' not found!\n";
754
755   my $new_year = $conf->config('census_year') or return;
756   my $loc = FS::GeocodeCache->new( $cust_location->location_hash );
757   $loc->set_censustract;
758   my $error = $loc->get('censustract_error');
759   die $error if $error;
760   $cust_location->set('censustract', $loc->get('censustract'));
761   $cust_location->set('censusyear',  $new_year);
762   $error = $cust_location->replace;
763   die $error if $error;
764   return;
765 }
766
767 =item process_set_coord
768
769 Queueable function to find and fill in coordinates for all locations that 
770 lack them.  Because this uses the Google Maps API, it's internally rate
771 limited and must run in a single process.
772
773 =cut
774
775 sub process_set_coord {
776   my $job = shift;
777   # avoid starting multiple instances of this job
778   my @others = qsearch('queue', {
779       'status'  => 'locked',
780       'job'     => $job->job,
781       'jobnum'  => {op=>'!=', value=>$job->jobnum},
782   });
783   return if @others;
784
785   $job->update_statustext('finding locations to update');
786   my @missing_coords = qsearch('cust_location', {
787       'disabled'  => '',
788       'latitude'  => '',
789       'longitude' => '',
790   });
791   my $i = 0;
792   my $n = scalar @missing_coords;
793   for my $cust_location (@missing_coords) {
794     $cust_location->set_coord;
795     my $error = $cust_location->replace;
796     if ( $error ) {
797       warn "error geocoding location#".$cust_location->locationnum.": $error\n";
798     } else {
799       $i++;
800       $job->update_statustext("updated $i / $n locations");
801       dbh->commit; # so that we don't have to wait for the whole thing to finish
802       # Rate-limit to stay under the Google Maps usage limit (2500/day).
803       # 86,400 / 35 = 2,468 lookups per day.
804     }
805     sleep 35;
806   }
807   if ( $i < $n ) {
808     die "failed to update ".$n-$i." locations\n";
809   }
810   return;
811 }
812
813 =item process_standardize [ LOCATIONNUMS ]
814
815 Performs address standardization on locations with unclean addresses,
816 using whatever method you have configured.  If the standardize_* method 
817 returns a I<clean> address match, the location will be updated.  This is 
818 always an in-place update (because the physical location is the same, 
819 and is just being referred to by a more accurate name).
820
821 Disabled locations will be skipped, as nobody cares.
822
823 If any LOCATIONNUMS are provided, only those locations will be updated.
824
825 =cut
826
827 sub process_standardize {
828   my $job = shift;
829   my @others = qsearch('queue', {
830       'status'  => 'locked',
831       'job'     => $job->job,
832       'jobnum'  => {op=>'!=', value=>$job->jobnum},
833   });
834   return if @others;
835   my @locationnums = grep /^\d+$/, @_;
836   my $where = "AND locationnum IN(".join(',',@locationnums).")"
837     if scalar(@locationnums);
838   my @locations = qsearch({
839       table     => 'cust_location',
840       hashref   => { addr_clean => '', disabled => '' },
841       extra_sql => $where,
842   });
843   my $n_todo = scalar(@locations);
844   my $n_done = 0;
845
846   # special: log this
847   my $log;
848   eval "use Text::CSV";
849   open $log, '>', "$FS::UID::cache_dir/process_standardize-" . 
850                   time2str('%Y%m%d',time) .
851                   ".csv";
852   my $csv = Text::CSV->new({binary => 1, eol => "\n"});
853
854   foreach my $cust_location (@locations) {
855     $job->update_statustext( int(100 * $n_done/$n_todo) . ",$n_done / $n_todo locations" ) if $job;
856     my $result = FS::GeocodeCache->standardize($cust_location);
857     if ( $result->{addr_clean} and !$result->{error} ) {
858       my @cols = ($cust_location->locationnum);
859       foreach (keys %$result) {
860         push @cols, $cust_location->get($_), $result->{$_};
861         $cust_location->set($_, $result->{$_});
862       }
863       # bypass immutable field restrictions
864       my $error = $cust_location->FS::Record::replace;
865       warn "location ".$cust_location->locationnum.": $error\n" if $error;
866       $csv->print($log, \@cols);
867     }
868     $n_done++;
869     dbh->commit; # so that we can resume if interrupted
870   }
871   close $log;
872 }
873
874 =head1 BUGS
875
876 =head1 SEE ALSO
877
878 L<FS::cust_main_county>, L<FS::cust_pkg>, L<FS::Record>,
879 schema.html from the base documentation.
880
881 =cut
882
883 1;
884