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