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