+=head1 CLASS METHODS
+
+=item in_county_sql OPTIONS
+
+Returns an SQL expression to test membership in a cust_main_county
+geographic area. By default, this requires district, city, county,
+state, and country to match exactly. Pass "ornull => 1" to allow
+partial matches where some fields are NULL in the cust_main_county
+record but not in the location.
+
+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 $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;
+}