use FS::prospect_main;
use FS::cust_main;
use FS::cust_main_county;
+use FS::GeocodeCache;
+use Date::Format qw( time2str );
$import = 0;
sub table { 'cust_location'; }
-=item new_or_existing HASHREF
+=item find_or_insert
-Returns an existing location matching the customer and address fields in
-HASHREF, if one exists; otherwise returns a new location containing those
-fields. The following fields must match: address1, address2, city, county,
-state, zip, country, geocode, disabled. Other fields are only required
-to match if they're specified in HASHREF.
+Finds an existing location matching the customer and address values in this
+location, if one exists, and sets the contents of this location equal to that
+one (including its locationnum).
-The new location will not be inserted; the calling code must call C<insert>
-(or a method such as C<move_to>) to insert it, and check for errors at that
-point.
+If an existing location is not found, this one I<will> be inserted. (This is a
+change from the "new_or_existing" method that this replaces.)
+
+The following fields are considered "essential" and I<must> match: custnum,
+address1, address2, city, county, state, zip, country, location_number,
+location_type, location_kind. Disabled locations will be found only if this
+location is set to disabled.
+
+If 'coord_auto' is null, and latitude and longitude are not null, then
+latitude and longitude are also essential fields.
+
+All other fields are considered "non-essential". If a non-essential field is
+empty in this location, it will be ignored in determining whether an existing
+location matches.
+
+If a non-essential field is non-empty in this location, existing locations
+that contain a different non-empty value for that field will not match. An
+existing location in which the field is I<empty> will match, but will be
+updated in-place with the value of that field.
+
+Returns an error string if inserting or updating a location failed.
+
+It is unfortunately hard to determine if this created a new location or not.
=cut
-sub new_or_existing {
- my $class = shift;
- my %hash = ref($_[0]) ? %{$_[0]} : @_;
- # if coords are empty, then it doesn't matter if they're auto or not
- if ( !$hash{'latitude'} and !$hash{'longitude'} ) {
- delete $hash{'coord_auto'};
+sub find_or_insert {
+ my $self = shift;
+
+ my @essential = (qw(custnum address1 address2 city county state zip country
+ location_number location_type location_kind disabled));
+
+ if ( !$self->coord_auto and $self->latitude and $self->longitude ) {
+ push @essential, qw(latitude longitude);
+ # but NOT coord_auto; if the latitude and longitude match the geocoded
+ # values then that's good enough
}
- foreach ( qw(address1 address2 city county state zip country geocode
- disabled ) ) {
- # empty fields match only empty fields
- $hash{$_} = '' if !defined($hash{$_});
+
+ # put nonempty, nonessential fields/values into this hash
+ my %nonempty = map { $_ => $self->get($_) }
+ grep {$self->get($_)} $self->fields;
+ delete @nonempty{@essential};
+ delete $nonempty{'locationnum'};
+
+ my %hash = map { $_ => $self->get($_) } @essential;
+ my @matches = qsearch('cust_location', \%hash);
+
+ # consider candidate locations
+ MATCH: foreach my $old (@matches) {
+ my $reject = 0;
+ foreach my $field (keys %nonempty) {
+ my $old_value = $old->get($field);
+ if ( length($old_value) > 0 ) {
+ if ( $field eq 'latitude' or $field eq 'longitude' ) {
+ # special case, because these are decimals
+ if ( abs($old_value - $nonempty{$field}) > 0.000001 ) {
+ $reject = 1;
+ }
+ } elsif ( $old_value ne $nonempty{$field} ) {
+ $reject = 1;
+ }
+ } else {
+ # it's empty in $old, has a value in $self
+ $old->set($field, $nonempty{$field});
+ }
+ next MATCH if $reject;
+ } # foreach $field
+
+ if ( $old->modified ) {
+ my $error = $old->replace;
+ return $error if $error;
+ }
+ # set $self equal to $old
+ foreach ($self->fields) {
+ $self->set($_, $old->get($_));
+ }
+ return "";
}
- return qsearchs('cust_location', \%hash) || $class->new(\%hash);
+
+ # didn't find a match
+ return $self->insert;
}
=item insert
=cut
-#some false laziness w/cust_main, but since it should eventually lose these
-#fields anyway...
sub check {
my $self = shift;
my $conf = new FS::Conf;
+ return '' if $self->disabled; # so that disabling locations never fails
+
my $error =
$self->ut_numbern('locationnum')
|| $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
} );
}
+ # set coordinates, unless we already have them
+ if (!$import and !$self->latitude and !$self->longitude) {
+ $self->set_coord;
+ }
+
$self->SUPER::check;
}
}
}
+ # find all packages that have the old location as their service address,
+ # and aren't canceled,
+ # and aren't supplemental to another package.
my @pkgs = qsearch('cust_pkg', {
'locationnum' => $old->locationnum,
- 'cancel' => ''
+ 'cancel' => '',
+ 'main_pkgnum' => '',
});
foreach my $cust_pkg (@pkgs) {
$error = $cust_pkg->change(
}
}
+=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
=head1 SEE ALSO