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