+Pass "param => 1" to receive a parameterized expression (rather than
+one that requires a join to cust_main_county) and a list of parameter
+names in order.
+
+=cut
+
+sub in_county_sql {
+ # replaces FS::cust_pkg::location_sql
+ my ($class, %opt) = @_;
+ my $ornull = $opt{ornull} ? ' OR ? IS NULL' : '';
+ my $x = $ornull ? 3 : 2;
+ my @fields = (('district') x 3,
+ ('city') x 3,
+ ('county') x $x,
+ ('state') x $x,
+ 'country');
+
+ my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
+
+ my @where = (
+ "cust_location.district = ? OR ? = '' OR CAST(? AS $text) IS NULL",
+ "cust_location.city = ? OR ? = '' OR CAST(? AS $text) IS NULL",
+ "cust_location.county = ? OR (? = '' AND cust_location.county IS NULL) $ornull",
+ "cust_location.state = ? OR (? = '' AND cust_location.state IS NULL ) $ornull",
+ "cust_location.country = ?"
+ );
+ my $sql = join(' AND ', map "($_)\n", @where);
+ if ( $opt{param} ) {
+ return $sql, @fields;
+ }
+ else {
+ # do the substitution here
+ foreach (@fields) {
+ $sql =~ s/\?/cust_main_county.$_/;
+ $sql =~ s/cust_main_county.$_ = ''/cust_main_county.$_ IS NULL/;
+ }
+ return $sql;
+ }
+}
+
+=back
+
+=head2 SUBROUTINES
+
+=over 4
+
+=item process_censustract_update LOCATIONNUM
+
+Queueable function to update the census tract to the current year (as set in
+the 'census_year' configuration variable) and retrieve the new tract code.
+
+=cut
+
+sub process_censustract_update {
+ eval "use FS::GeocodeCache";
+ die $@ if $@;
+ my $locationnum = shift;
+ my $cust_location =
+ qsearchs( 'cust_location', { locationnum => $locationnum })
+ or die "locationnum '$locationnum' not found!\n";
+
+ my $conf = FS::Conf->new;
+ my $new_year = $conf->config('census_year') or return;
+ my $loc = FS::GeocodeCache->new( $cust_location->location_hash );
+ $loc->set_censustract;
+ my $error = $loc->get('censustract_error');
+ die $error if $error;
+ $cust_location->set('censustract', $loc->get('censustract'));
+ $cust_location->set('censusyear', $new_year);
+ $error = $cust_location->replace;
+ die $error if $error;
+ return;
+}
+
+=item process_set_coord
+
+Queueable function to find and fill in coordinates for all locations that
+lack them. Because this uses the Google Maps API, it's internally rate
+limited and must run in a single process.
+
+=cut
+
+sub process_set_coord {
+ my $job = shift;
+ # avoid starting multiple instances of this job
+ my @others = qsearch('queue', {
+ 'status' => 'locked',
+ 'job' => $job->job,
+ 'jobnum' => {op=>'!=', value=>$job->jobnum},
+ });
+ return if @others;
+
+ $job->update_statustext('finding locations to update');
+ my @missing_coords = qsearch('cust_location', {
+ 'disabled' => '',
+ 'latitude' => '',
+ 'longitude' => '',
+ });
+ my $i = 0;
+ my $n = scalar @missing_coords;
+ for my $cust_location (@missing_coords) {
+ $cust_location->set_coord;
+ my $error = $cust_location->replace;
+ if ( $error ) {
+ warn "error geocoding location#".$cust_location->locationnum.": $error\n";
+ } else {
+ $i++;
+ $job->update_statustext("updated $i / $n locations");
+ dbh->commit; # so that we don't have to wait for the whole thing to finish
+ # Rate-limit to stay under the Google Maps usage limit (2500/day).
+ # 86,400 / 35 = 2,468 lookups per day.
+ }
+ sleep 35;
+ }
+ if ( $i < $n ) {
+ die "failed to update ".$n-$i." locations\n";
+ }
+ return;
+}
+
+=item process_standardize [ LOCATIONNUMS ]
+
+Performs address standardization on locations with unclean addresses,
+using whatever method you have configured. If the standardize_* method
+returns a I<clean> address match, the location will be updated. This is
+always an in-place update (because the physical location is the same,
+and is just being referred to by a more accurate name).
+
+Disabled locations will be skipped, as nobody cares.
+
+If any LOCATIONNUMS are provided, only those locations will be updated.
+
+=cut
+
+sub process_standardize {
+ my $job = shift;
+ my @others = qsearch('queue', {
+ 'status' => 'locked',
+ 'job' => $job->job,
+ 'jobnum' => {op=>'!=', value=>$job->jobnum},
+ });
+ return if @others;
+ my @locationnums = grep /^\d+$/, @_;
+ my $where = "AND locationnum IN(".join(',',@locationnums).")"
+ if scalar(@locationnums);
+ my @locations = qsearch({
+ table => 'cust_location',
+ hashref => { addr_clean => '', disabled => '' },
+ extra_sql => $where,
+ });
+ my $n_todo = scalar(@locations);
+ my $n_done = 0;
+
+ # special: log this
+ my $log;
+ eval "use Text::CSV";
+ open $log, '>', "$FS::UID::cache_dir/process_standardize-" .
+ time2str('%Y%m%d',time) .
+ ".csv";
+ my $csv = Text::CSV->new({binary => 1, eol => "\n"});
+
+ foreach my $cust_location (@locations) {
+ $job->update_statustext( int(100 * $n_done/$n_todo) . ",$n_done / $n_todo locations" ) if $job;
+ my $result = FS::GeocodeCache->standardize($cust_location);
+ if ( $result->{addr_clean} and !$result->{error} ) {
+ my @cols = ($cust_location->locationnum);
+ foreach (keys %$result) {
+ push @cols, $cust_location->get($_), $result->{$_};
+ $cust_location->set($_, $result->{$_});
+ }
+ # bypass immutable field restrictions
+ my $error = $cust_location->FS::Record::replace;
+ warn "location ".$cust_location->locationnum.": $error\n" if $error;
+ $csv->print($log, \@cols);
+ }
+ $n_done++;
+ dbh->commit; # so that we can resume if interrupted
+ }
+ close $log;
+}
+
+=head1 BUGS