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