RT# 83450 - fixed rateplan export
[freeside.git] / FS / FS / geocode_Mixin.pm
index 29491db..2b43045 100644 (file)
@@ -3,14 +3,18 @@ package FS::geocode_Mixin;
 use strict;
 use vars qw( $DEBUG $me );
 use Carp;
 use strict;
 use vars qw( $DEBUG $me );
 use Carp;
-use Locale::Country;
-use Geo::Coder::Googlev3; #compile time for now, until others are supported
+use Cpanel::JSON::XS;
+use Data::Dumper;
+use Locale::Country ();
+use LWP::UserAgent;
+use URI::Escape;
 use FS::Record qw( qsearchs qsearch );
 use FS::Conf;
 use FS::cust_pkg;
 use FS::cust_location;
 use FS::cust_tax_location;
 use FS::part_pkg;
 use FS::Record qw( qsearchs qsearch );
 use FS::Conf;
 use FS::cust_pkg;
 use FS::cust_location;
 use FS::cust_tax_location;
 use FS::part_pkg;
+use FS::part_pkg_taxclass;
 
 $DEBUG = 0;
 $me = '[FS::geocode_Mixin]';
 
 $DEBUG = 0;
 $me = '[FS::geocode_Mixin]';
@@ -126,51 +130,102 @@ sub location_label {
       $notfirst++;
     }
   }
       $notfirst++;
     }
   }
-  $line .= $separator. &$escape(code2country($self->country))
+  $line .= $separator. &$escape($self->country_full)
     if $self->country ne $cydefault;
 
   $line;
 }
 
     if $self->country ne $cydefault;
 
   $line;
 }
 
-=item set_coord [ PREFIX ]
+=item country_full
+
+Returns the full country name.
+
+=cut
+
+sub country_full {
+  my $self = shift;
+  $self->code2country($self->get('country'));
+}
+
+sub code2country {
+  my( $self, $country ) = @_;
+
+  #a hash?  not expecting an explosion of business from unrecognized countries..
+  return 'KKTC' if $country eq 'XC';
+                                           
+  Locale::Country::code2country($country);
+}
+
+=item set_coord
 
 Look up the coordinates of the location using (currently) the Google Maps
 API and set the 'latitude' and 'longitude' fields accordingly.
 
 
 Look up the coordinates of the location using (currently) the Google Maps
 API and set the 'latitude' and 'longitude' fields accordingly.
 
-PREFIX, if specified, will be prepended to all location field names,
-including latitude and longitude.
-
 =cut
 
 sub set_coord {
   my $self = shift;
 =cut
 
 sub set_coord {
   my $self = shift;
-  my $pre = scalar(@_) ? shift : '';
-
-  #my $module = FS::Conf->new->config('geocode_module') || 'Geo::Coder::Googlev3';
-
-  my $geocoder = Geo::Coder::Googlev3->new;
-
-  my $location = eval {
-    $geocoder->geocode( location =>
-      $self->get($pre.'address1'). ','.
-      ( $self->get($pre.'address2') ? $self->get($pre.'address2').',' : '' ).
-      $self->get($pre.'city'). ','.
-      $self->get($pre.'state'). ','.
-      code2country($self->get($pre.'country'))
-    );
-  };
-  if ( $@ ) {
-    warn "geocoding error: $@\n";
+
+  # Google documetnation:
+  # https://developers.google.com/maps/documentation/geocoding/start
+
+
+  my $api_key = FS::Conf->new->config('google_maps_api_key');
+
+  unless ( $api_key ) {
+    # Google API now requires a valid key with a payment method attached
+    warn 'Geocoding unavailable, install a google_maps_api_key';
     return;
   }
 
     return;
   }
 
-  my $geo_loc = $location->{'geometry'}{'location'} or return;
-  if ( $geo_loc->{'lat'} && $geo_loc->{'lng'} ) {
-    $self->set($pre.'latitude',  $geo_loc->{'lat'} );
-    $self->set($pre.'longitude', $geo_loc->{'lng'} );
-    $self->set($pre.'coord_auto', 'Y');
+  my $google_api_url = 'https://maps.googleapis.com/maps/api/geocode/json';
+
+  my $address =
+    join ',',
+    map { $self->get( $_ ) ? uri_escape( $self->get( $_ ) ) : () }
+    qw( address1 address2 city state zip country_full );
+
+  my $query_url = sprintf
+    '%s?address=%s&key=%s',
+    $google_api_url, $address, $api_key;
+
+  my $ua = LWP::UserAgent->new;
+  $ua->timeout(10);
+  my $res = $ua->get( $query_url );
+  my $json_res = decode_json( $res->decoded_content );
+  my $json_error = $json_res->{error_message}
+    if ref $json_res && $json_res->{error_message};
+
+  if ( $DEBUG ) {
+    warn "\$query_url: $query_url\n";
+    warn "\$json_error: $json_error\n";
+    warn Dumper( $json_res || $res->decoded_content )."\n";
   }
 
   }
 
+  if ( !$res->is_success || $json_error ) {
+    warn "Error using google GeoCoding API";
+    warn Dumper( $json_res || $res->decoded_content );
+    return;
+  }
+  
+  if (
+       ref $json_res
+    && ref $json_res->{results}
+    && ref $json_res->{results}->[0]
+    && ref $json_res->{results}->[0]->{geometry}
+    && ref $json_res->{results}->[0]->{geometry}->{location}
+  ) {
+    my $location = $json_res->{results}->[0]->{geometry}->{location};
+    if ( $location->{lat} && $location->{lng} ) {
+      $self->set( latitude   => $location->{lat} );
+      $self->set( longitude  => $location->{lng} );
+      $self->set( coord_auto => 'Y' );
+    }
+  } else {
+    # If google changes the API response structure, warnings abound
+    warn "No location match found using google GeoCoding API for $address";
+    warn Dumper( $json_res || $res->decoded_content );
+  }
 }
 
 =item geocode DATA_VENDOR
 }
 
 =item geocode DATA_VENDOR
@@ -186,12 +241,17 @@ sub geocode {
   my $geocode = $self->get('geocode');  #XXX only one data_vendor for geocode
   return $geocode if $geocode;
 
   my $geocode = $self->get('geocode');  #XXX only one data_vendor for geocode
   return $geocode if $geocode;
 
-  my $prefix =
-   ( FS::Conf->new->exists('tax-ship_address') && $self->has_ship_address )
-   ? 'ship_'
-   : '';
+  if ( $self->isa('FS::cust_main') ) {
+    warn "WARNING: FS::cust_main->geocode deprecated";
+
+    # do the best we can
+    my $m = FS::Conf->new->exists('tax-ship_address') ? 'ship_location'
+                                                      : 'bill_location';
+    my $location = $self->$m or return '';
+    return $location->geocode($data_vendor);
+  }
 
 
-  my($zip,$plus4) = split /-/, $self->get("${prefix}zip")
+  my($zip,$plus4) = split /-/, $self->get('zip')
     if $self->country eq 'US';
 
   $zip ||= '';
     if $self->country eq 'US';
 
   $zip ||= '';
@@ -212,7 +272,7 @@ sub geocode {
     if scalar(@cust_tax_location);
 
   warn "WARNING: customer ". $self->custnum.
     if scalar(@cust_tax_location);
 
   warn "WARNING: customer ". $self->custnum.
-       ": multiple locations for zip ". $self->get("${prefix}zip").
+       ": multiple locations for zip ". $self->get("zip").
        "; using arbitrary geocode $geocode\n"
     if scalar(@cust_tax_location) > 1;
 
        "; using arbitrary geocode $geocode\n"
     if scalar(@cust_tax_location) > 1;
 
@@ -227,43 +287,103 @@ Queueable function to update the tax district code using the selected method
 
 =cut
 
 
 =cut
 
+# this is run from the job queue so I'm not transactionizing it.
+
 sub process_district_update {
   my $class = shift;
   my $id = shift;
 sub process_district_update {
   my $class = shift;
   my $id = shift;
+  my $log = FS::Log->new('FS::cust_location::process_district_update');
 
   eval "use FS::Misc::Geo qw(get_district); use FS::Conf; use $class;";
   die $@ if $@;
   die "$class has no location data" if !$class->can('location_hash');
 
 
   eval "use FS::Misc::Geo qw(get_district); use FS::Conf; use $class;";
   die $@ if $@;
   die "$class has no location data" if !$class->can('location_hash');
 
+  my $error;
   my $conf = FS::Conf->new;
   my $method = $conf->config('tax_district_method')
     or return; #nothing to do if null
   my $self = $class->by_key($id) or die "object $id not found";
   my $conf = FS::Conf->new;
   my $method = $conf->config('tax_district_method')
     or return; #nothing to do if null
   my $self = $class->by_key($id) or die "object $id not found";
+  return if $self->disabled;
 
   # dies on error, fine
   my $tax_info = get_district({ $self->location_hash }, $method);
 
   # dies on error, fine
   my $tax_info = get_district({ $self->location_hash }, $method);
-  
-  if ( $tax_info ) {
+  return unless $tax_info;
+
+  if ($self->district ne $tax_info->{'district'}) {
     $self->set('district', $tax_info->{'district'} );
     $self->set('district', $tax_info->{'district'} );
-    my $error = $self->replace;
+    $error = $self->replace;
     die $error if $error;
     die $error if $error;
+  }
 
 
-    my %hash = map { $_ => $tax_info->{$_} } 
-      qw( district city county state country );
-    my $old = qsearchs('cust_main_county', \%hash);
-    if ( $old ) {
-      my $new = new FS::cust_main_county { $old->hash, %$tax_info };
-      warn "updating tax rate for district ".$tax_info->{'district'} if $DEBUG;
-      $error = $new->replace($old);
-    }
-    else {
-      my $new = new FS::cust_main_county $tax_info;
-      warn "creating tax rate for district ".$tax_info->{'district'} if $DEBUG;
+  my %hash = map { $_ => uc( $tax_info->{$_} ) } 
+    qw( district city county state country );
+  $hash{'source'} = $method; # apply the update only to taxes we maintain
+
+  my @classes = FS::part_pkg_taxclass->taxclass_names;
+  my $taxname = $conf->config('tax_district_taxname');
+  # there must be exactly one cust_main_county for each district+taxclass.
+  # do NOT exclude taxes that are zero.
+
+  # mutex here so that concurrent queue jobs can't make duplicates.
+  FS::cust_main_county->lock_table;
+  foreach my $taxclass (@classes) {
+    my @existing = qsearch('cust_main_county', {
+      %hash,
+      'taxclass' => $taxclass
+    });
+
+    if ( scalar(@existing) == 0 ) {
+
+      # then create one with the assigned tax name, and the tax rate from
+      # the lookup.
+      my $new = new FS::cust_main_county({
+        %hash,
+        'taxclass'      => $taxclass,
+        'taxname'       => $taxname,
+        'tax'           => $tax_info->{tax},
+        'exempt_amount' => 0,
+      });
+      $log->info("creating tax rate for district ".$tax_info->{'district'});
       $error = $new->insert;
       $error = $new->insert;
+
+    } else {
+
+      my $to_update = $existing[0];
+      # if there's somehow more than one, find the best candidate to be
+      # updated:
+      # - prefer tax > 0 over tax = 0 (leave disabled records disabled)
+      # - then, prefer taxname = the designated taxname
+      if ( scalar(@existing) > 1 ) {
+        $log->warning("tax district ".$tax_info->{district}." has multiple $method taxes.");
+        foreach (@existing) {
+          if ( $to_update->tax == 0 ) {
+            if ( $_->tax > 0 and $to_update->tax == 0 ) {
+              $to_update = $_;
+            } elsif ( $_->tax == 0 and $to_update->tax > 0 ) {
+              next;
+            } elsif ( $_->taxname eq $taxname and $to_update->tax ne $taxname ) {
+              $to_update = $_;
+            }
+          }
+        }
+        # don't remove the excess records here; upgrade does that.
+      }
+      my $taxnum = $to_update->taxnum;
+      if ( $to_update->tax == 0 ) {
+        $log->debug("tax#$taxnum is set to zero; not updating.");
+      } elsif ( $to_update->tax == $tax_info->{tax} ) {
+        # do nothing, no need to update
+      } else {
+        $to_update->set('tax', $tax_info->{tax});
+        $log->info("updating tax#$taxnum with new rate ($tax_info->{tax}).");
+        $error = $to_update->replace;
+      }
     }
     }
+
     die $error if $error;
 
     die $error if $error;
 
-  }
+  } # foreach $taxclass
+
   return;
 }
 
   return;
 }