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