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