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