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