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