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