RT# 83450 - fixed rateplan export
[freeside.git] / FS / FS / cust_location.pm
index b0a4fea..21bf92f 100644 (file)
@@ -2,7 +2,7 @@ package FS::cust_location;
 use base qw( FS::geocode_Mixin FS::Record );
 
 use strict;
 use base qw( FS::geocode_Mixin FS::Record );
 
 use strict;
-use vars qw( $import $DEBUG $conf $label_prefix );
+use vars qw( $import $DEBUG $conf $label_prefix $allow_location_edit );
 use Data::Dumper;
 use Date::Format qw( time2str );
 use FS::UID qw( dbh driver_name );
 use Data::Dumper;
 use Date::Format qw( time2str );
 use FS::UID qw( dbh driver_name );
@@ -14,6 +14,12 @@ use FS::cust_main_county;
 use FS::part_export;
 use FS::GeocodeCache;
 
 use FS::part_export;
 use FS::GeocodeCache;
 
+# Essential fields. Can't be modified in place, will be considered in
+# deciding if a location is "new", and (because of that) can't have
+# leading/trailing whitespace.
+my @essential = (qw(custnum address1 address2 city county state zip country
+  location_number location_type location_kind disabled));
+
 $import = 0;
 
 $DEBUG = 0;
 $import = 0;
 
 $DEBUG = 0;
@@ -44,8 +50,9 @@ FS::cust_location - Object methods for cust_location records
 
 =head1 DESCRIPTION
 
 
 =head1 DESCRIPTION
 
-An FS::cust_location object represents a customer location.  FS::cust_location
-inherits from FS::Record.  The following fields are currently supported:
+An FS::cust_location object represents a customer (or prospect) location.
+FS::cust_location inherits from FS::Record.  The following fields are currently
+supported:
 
 =over 4
 
 
 =over 4
 
@@ -55,7 +62,15 @@ primary key
 
 =item custnum
 
 
 =item custnum
 
-custnum
+Customer (see L<FS::cust_main>).
+
+=item prospectnum
+
+Prospect (see L<FS::prospect_main>).
+
+=item locationname
+
+Optional location name.
 
 =item address1
 
 
 =item address1
 
@@ -89,6 +104,23 @@ Country (see L<FS::cust_main_county>)
 
 Geocode
 
 
 Geocode
 
+=item latitude
+
+=item longitude
+
+=item coord_auto
+
+Flag indicating whether coordinates were obtained automatically or manually
+entered
+
+=item addr_clean
+
+Flag indicating whether address has been normalized
+
+=item censustract
+
+=item censusyear
+
 =item district
 
 Tax district code (optional)
 =item district
 
 Tax district code (optional)
@@ -148,9 +180,6 @@ sub find_or_insert {
 
   warn "find_or_insert:\n".Dumper($self) if $DEBUG;
 
 
   warn "find_or_insert:\n".Dumper($self) if $DEBUG;
 
-  my @essential = (qw(custnum address1 address2 city county state zip country
-    location_number location_type location_kind disabled));
-
   if ($conf->exists('cust_main-no_city_in_address')) {
     warn "Warning: passed city to find_or_insert when cust_main-no_city_in_address is configured, ignoring it"
       if $self->get('city');
   if ($conf->exists('cust_main-no_city_in_address')) {
     warn "Warning: passed city to find_or_insert when cust_main-no_city_in_address is configured, ignoring it"
       if $self->get('city');
@@ -171,6 +200,10 @@ sub find_or_insert {
   delete $nonempty{'locationnum'};
 
   my %hash = map { $_ => $self->get($_) } @essential;
   delete $nonempty{'locationnum'};
 
   my %hash = map { $_ => $self->get($_) } @essential;
+  foreach (values %hash) {
+    s/^\s+//;
+    s/\s+$//;
+  }
   my @matches = qsearch('cust_location', \%hash);
 
   # we no longer reject matches for having different values in nonessential
   my @matches = qsearch('cust_location', \%hash);
 
   # we no longer reject matches for having different values in nonessential
@@ -232,8 +265,15 @@ sub insert {
     return $error;
   }
 
     return $error;
   }
 
-  #false laziness with cust_main, will go away eventually
-  if ( !$import and $conf->config('tax_district_method') ) {
+  # If using tax_district_method, for rows in state of Washington,
+  # without a tax district already specified, queue a job to find
+  # the tax district
+  if (
+       !$import
+    && !$self->district
+    && lc $self->state eq 'wa'
+    && $conf->config('tax_district_method')
+  ) {
 
     my $queue = new FS::queue {
       'job' => 'FS::geocode_Mixin::process_district_update'
 
     my $queue = new FS::queue {
       'job' => 'FS::geocode_Mixin::process_district_update'
@@ -249,20 +289,22 @@ sub insert {
   # cust_location exports
   #my $export_args = $options{'export_args'} || [];
 
   # cust_location exports
   #my $export_args = $options{'export_args'} || [];
 
-  my @part_export =
-    map qsearch( 'part_export', {exportnum=>$_} ),
-      $conf->config('cust_location-exports'); #, $agentnum
-
-  foreach my $part_export ( @part_export ) {
-    my $error = $part_export->export_insert($self); #, @$export_args);
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "exporting to ". $part_export->exporttype.
-             " (transaction rolled back): $error";
+  # don't export custnum_pending cases, let follow-up replace handle that
+  if ($self->custnum || $self->prospectnum) {
+    my @part_export =
+      map qsearch( 'part_export', {exportnum=>$_} ),
+        $conf->config('cust_location-exports'); #, $agentnum
+
+    foreach my $part_export ( @part_export ) {
+      my $error = $part_export->export_insert($self); #, @$export_args);
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "exporting to ". $part_export->exporttype.
+               " (transaction rolled back): $error";
+      }
     }
   }
 
     }
   }
 
-
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
 }
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
 }
@@ -286,10 +328,15 @@ sub replace {
   warn "Warning: passed city to replace when cust_main-no_city_in_address is configured"
     if $conf->exists('cust_main-no_city_in_address') && $self->get('city');
 
   warn "Warning: passed city to replace when cust_main-no_city_in_address is configured"
     if $conf->exists('cust_main-no_city_in_address') && $self->get('city');
 
-  # the following fields are immutable
-  foreach (qw(address1 address2 city state zip country)) {
-    if ( $self->$_ ne $old->$_ ) {
-      return "can't change cust_location field $_";
+  # the following fields are immutable if this is a customer location. if
+  # it's a prospect location, then there are no active packages, no billing
+  # history, no taxes, and in general no reason to keep the old location
+  # around.
+  if ( !$allow_location_edit and $self->custnum ) {
+    foreach (qw(address1 address2 city state zip country)) {
+      if ( $self->$_ ne $old->$_ ) {
+        return "can't change cust_location field $_";
+      }
     }
   }
 
     }
   }
 
@@ -306,20 +353,22 @@ sub replace {
   # cust_location exports
   #my $export_args = $options{'export_args'} || [];
 
   # cust_location exports
   #my $export_args = $options{'export_args'} || [];
 
-  my @part_export =
-    map qsearch( 'part_export', {exportnum=>$_} ),
-      $conf->config('cust_location-exports'); #, $agentnum
-
-  foreach my $part_export ( @part_export ) {
-    my $error = $part_export->export_replace($self, $old); #, @$export_args);
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "exporting to ". $part_export->exporttype.
-             " (transaction rolled back): $error";
+  # don't export custnum_pending cases, let follow-up replace handle that
+  if ($self->custnum || $self->prospectnum) {
+    my @part_export =
+      map qsearch( 'part_export', {exportnum=>$_} ),
+        $conf->config('cust_location-exports'); #, $agentnum
+
+    foreach my $part_export ( @part_export ) {
+      my $error = $part_export->export_replace($self, $old); #, @$export_args);
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "exporting to ". $part_export->exporttype.
+               " (transaction rolled back): $error";
+      }
     }
   }
 
     }
   }
 
-
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
 }
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
 }
@@ -338,6 +387,10 @@ sub check {
 
   return '' if $self->disabled; # so that disabling locations never fails
 
 
   return '' if $self->disabled; # so that disabling locations never fails
 
+  # whitespace in essential fields leads to problems figuring out if a
+  # record is "new"; get rid of it.
+  $self->trim_whitespace(@essential);
+
   my $error = 
     $self->ut_numbern('locationnum')
     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
   my $error = 
     $self->ut_numbern('locationnum')
     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
@@ -372,10 +425,12 @@ sub check {
     $self->censustract("$1.$2");
   }
 
     $self->censustract("$1.$2");
   }
 
-  if ( $conf->exists('cust_main-require_address2') and 
-       !$self->ship_address2 =~ /\S/ ) {
-    return "Unit # is required";
-  }
+  #yikes... this is ancient, pre-dates cust_location and will be harder to
+  # implement now... how do we know this location is a service location from
+  # here and not a billing? we can't just check locationnums, we might be new :/
+  return "Unit # is required"
+    if $conf->exists('cust_main-require_address2')
+    && ! $self->address2 =~ /\S/;
 
   # tricky...we have to allow for the customer to not be inserted yet
   return "No prospect or customer!" unless $self->prospectnum 
 
   # tricky...we have to allow for the customer to not be inserted yet
   return "No prospect or customer!" unless $self->prospectnum 
@@ -388,6 +443,26 @@ sub check {
     && $conf->exists('prospect_main-alt_address_format')
     && ! $self->location_kind;
 
     && $conf->exists('prospect_main-alt_address_format')
     && ! $self->location_kind;
 
+  # Do not allow bad tax district values in cust_location when
+  # using Washington State district sales tax calculation - would result
+  # in incorrect or missing sales tax on invoices.
+  my $tax_district_method = FS::Conf->new->config('tax_district_method');
+  if (
+    $tax_district_method
+    && $tax_district_method eq 'wa_sales'
+    && $self->district
+  ) {
+    my $cust_main_county = qsearchs(
+      cust_main_county => { district => $self->district }
+    );
+    unless ( ref $cust_main_county ) {
+      return sprintf (
+        'WA State tax district %s does not exist in tax table',
+        $self->district
+      );
+    }
+  }
+
   unless ( $import or qsearch('cust_main_county', {
     'country' => $self->country,
     'state'   => '',
   unless ( $import or qsearch('cust_main_county', {
     'country' => $self->country,
     'state'   => '',
@@ -470,17 +545,51 @@ sub disable_if_unused {
 
 }
 
 
 }
 
-=item move_to
+=item move_pkgs
+
+Returns array of cust_pkg objects that would have their location
+updated by L</move_to> (all packages that have this location as 
+their service address, and aren't canceled, and aren't supplemental 
+to another package, and aren't one-time charges that have already been charged.)
+
+=cut
+
+sub move_pkgs {
+  my $self = shift;
+  my @pkgs = ();
+  # find all packages that have the old location as their service address,
+  # and aren't canceled,
+  # and aren't supplemental to another package
+  # and aren't one-time charges that have already been charged
+  foreach my $cust_pkg (
+    qsearch('cust_pkg', { 
+      'locationnum' => $self->locationnum,
+      'cancel'      => '',
+      'main_pkgnum' => '',
+    })
+  ) {
+    next if $cust_pkg->part_pkg->freq eq '0'
+            and ($cust_pkg->setup || 0) > 0;
+    push @pkgs, $cust_pkg;
+  }
+  return @pkgs;
+}
+
+=item move_to NEW [ move_pkgs => \@move_pkgs ]
 
 Takes a new L<FS::cust_location> object.  Moves all packages that use the 
 existing location to the new one, then sets the "disabled" flag on the old
 location.  Returns nothing on success, an error message on error.
 
 
 Takes a new L<FS::cust_location> object.  Moves all packages that use the 
 existing location to the new one, then sets the "disabled" flag on the old
 location.  Returns nothing on success, an error message on error.
 
+Use option I<move_pkgs> to override the list of packages to update
+(see L</move_pkgs>.)
+
 =cut
 
 sub move_to {
   my $old = shift;
   my $new = shift;
 =cut
 
 sub move_to {
   my $old = shift;
   my $new = shift;
+  my %opt = @_;
   
   warn "move_to:\nFROM:".Dumper($old)."\nTO:".Dumper($new) if $DEBUG;
 
   
   warn "move_to:\nFROM:".Dumper($old)."\nTO:".Dumper($new) if $DEBUG;
 
@@ -512,19 +621,32 @@ sub move_to {
     return '';
   }
 
     return '';
   }
 
-  # 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'      => '',
-      'main_pkgnum' => '',
-    });
-  foreach my $cust_pkg (@pkgs) {
-    # don't move one-time charges that have already been charged
-    next if $cust_pkg->part_pkg->freq eq '0'
-            and ($cust_pkg->setup || 0) > 0;
+  my @pkgs;
+  if ($opt{'move_pkgs'}) {
+    @pkgs = @{$opt{'move_pkgs'}};
+    my $pkgerr;
+    foreach my $pkg (@pkgs) {
+      my $pkgnum = $pkg->pkgnum;
+      $pkgerr = "cust_pkg $pkgnum has already been charged"
+        if $pkg->part_pkg->freq eq '0'
+          and ($pkg->setup || 0) > 0;
+      $pkgerr = "cust_pkg $pkgnum is supplemental"
+        if $pkg->main_pkgnum;
+      $pkgerr = "cust_pkg $pkgnum already cancelled"
+        if $pkg->cancel;
+      $pkgerr = "cust_pkg $pkgnum does not use this location"
+        unless $pkg->locationnum eq $old->locationnum;
+      last if $pkgerr;
+    }
+    if ($pkgerr) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Cannot update package location: $pkgerr";
+    }
+  } else {
+    @pkgs = $old->move_pkgs;
+  }
 
 
+  foreach my $cust_pkg (@pkgs) {
     $error = $cust_pkg->change(
       'locationnum' => $new->locationnum,
       'keep_dates'  => 1
     $error = $cust_pkg->change(
       'locationnum' => $new->locationnum,
       'keep_dates'  => 1
@@ -655,7 +777,7 @@ May become the default at some point.
 sub location_label {
   my( $self, %opt ) = @_;
 
 sub location_label {
   my( $self, %opt ) = @_;
 
-  my $prefix = $self->label_prefix;
+  my $prefix = $self->label_prefix(%opt);
   $prefix .= ($opt{join_string} ||  ': ') if $prefix;
   $prefix = '' if $opt{'no_prefix'};
 
   $prefix .= ($opt{join_string} ||  ': ') if $prefix;
   $prefix = '' if $opt{'no_prefix'};
 
@@ -711,15 +833,18 @@ sub label_prefix {
   } elsif ( $label_prefix eq '_location' && $self->locationname ) {
     $prefix = $self->locationname;
 
   } elsif ( $label_prefix eq '_location' && $self->locationname ) {
     $prefix = $self->locationname;
 
-  } elsif (    ( $opt{'cust_main'} || $self->custnum )
-          && $self->locationnum == $cust_or_prospect->ship_locationnum ) {
-    $prefix = 'Default service location';
+  #} elsif (    ( $opt{'cust_main'} || $self->custnum )
+  #        && $self->locationnum == $cust_or_prospect->ship_locationnum ) {
+  #  $prefix = 'Default service location';
+  #}
+  } else {
+    $prefix = '';
   }
 
   $prefix;
 }
 
   }
 
   $prefix;
 }
 
-=item county_state_county
+=item county_state_country
 
 Returns a string consisting of just the county, state and country.
 
 
 Returns a string consisting of just the county, state and country.
 
@@ -873,6 +998,41 @@ sub process_standardize {
   close $log;
 }
 
   close $log;
 }
 
+sub _upgrade_data {
+  my $class = shift;
+
+  # are we going to need to update tax districts?
+  my $use_districts = $conf->config('tax_district_method') ? 1 : 0;
+
+  # trim whitespace on records that need it
+  local $allow_location_edit = 1;
+  foreach my $field (@essential) {
+    next if $field eq 'custnum';
+    next if $field eq 'disabled';
+    foreach my $location (qsearch({
+      table => 'cust_location',
+      extra_sql => " WHERE disabled IS NULL AND ($field LIKE ' %' OR $field LIKE '% ')"
+    })) {
+      my $error = $location->replace;
+      die "$error (fixing whitespace in $field, locationnum ".$location->locationnum.')'
+        if $error;
+
+      if (
+        $use_districts
+        && !$location->district
+        && lc $location->state eq 'wa'
+      ) {
+        my $queue = new FS::queue {
+          'job' => 'FS::geocode_Mixin::process_district_update'
+        };
+        $error = $queue->insert( 'FS::cust_location' => $location->locationnum );
+        die $error if $error;
+      }
+    } # foreach $location
+  } # foreach $field
+  '';
+}
+
 =head1 BUGS
 
 =head1 SEE ALSO
 =head1 BUGS
 
 =head1 SEE ALSO