customer merging, RT#10247
authorivan <ivan>
Wed, 20 Oct 2010 02:07:02 +0000 (02:07 +0000)
committerivan <ivan>
Wed, 20 Oct 2010 02:07:02 +0000 (02:07 +0000)
14 files changed:
FS/FS/AccessRight.pm
FS/FS/Conf.pm
FS/FS/cust_bill.pm
FS/FS/cust_credit.pm
FS/FS/cust_main.pm
FS/FS/cust_pay.pm
FS/FS/cust_pay_void.pm
FS/FS/cust_refund.pm
FS/FS/option_Common.pm
bin/merge-referrals [new file with mode: 0644]
httemplate/elements/search-cust_main.html
httemplate/misc/cust_main-merge.html [new file with mode: 0755]
httemplate/misc/merge_cust.html [new file with mode: 0644]
httemplate/view/cust_main.cgi

index 92c4d22..6c06ec2 100644 (file)
@@ -113,6 +113,7 @@ tie my %rights, 'Tie::IxHash',
     'View customer history',
     'Cancel customer',
     'Complimentary customer', #aka users-allow_comp 
+    'Merge customer',
     { rightname=>'Delete customer', desc=>"Enable customer deletions. Be very careful! Deleting a customer will remove all traces that this customer ever existed! It should probably only be used when auditing a legacy database. Normally, you cancel all of a customer's packages if they cancel service." }, #aka. deletecustomers
     'Bill customer now', #NEW
     'Bulk send customer notices', #NEW
@@ -346,6 +347,7 @@ sub default_superuser_rights {
     'Redownload resolved batches',
     'Raw SQL',
     'Configuration download',
+    'View customers of all agents',
   );
 
   no warnings 'uninitialized';
index 42efa79..980bd62 100644 (file)
@@ -2718,7 +2718,7 @@ and customer address. Include units.',
   {
     'key'         => 'tax-pkg_address',
     'section'     => 'billing',
-    'description' => 'By default, tax calculations are done based on the billing address.  Enable this switch to calculate tax based on the package address instead (when present).  Note that this option is currently incompatible with vendor data taxation enabled by enable_taxproducts.',
+    'description' => 'By default, tax calculations are done based on the billing address.  Enable this switch to calculate tax based on the package address instead (when present).',
     'type'        => 'checkbox',
   },
 
index d9c2c40..0cf0526 100644 (file)
@@ -263,13 +263,13 @@ sub delete {
 
 }
 
-=item replace OLD_RECORD
+=item replace [ OLD_RECORD ]
 
-Replaces the OLD_RECORD with this one in the database.  If there is an error,
-returns the error, otherwise returns false.
+You can, but probably shouldn't modify invoices...
 
-Only printed may be changed.  printed is normally updated by calling the
-collect method of a customer object (see L<FS::cust_main>).
+Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not
+supplied, replaces this record.  If there is an error, returns the error,
+otherwise returns false.
 
 =cut
 
@@ -280,11 +280,11 @@ collect method of a customer object (see L<FS::cust_main>).
 
 sub replace_check {
   my( $new, $old ) = ( shift, shift );
-  return "Can't change custnum!" unless $old->custnum == $new->custnum;
+  return "Can't modify closed invoice" if $old->closed =~ /^Y/i;
   #return "Can't change _date!" unless $old->_date eq $new->_date;
-  return "Can't change _date!" unless $old->_date == $new->_date;
-  return "Can't change charged!" unless $old->charged == $new->charged
-                                     || $old->charged == 0;
+  return "Can't change _date" unless $old->_date == $new->_date;
+  return "Can't change charged" unless $old->charged == $new->charged
+                                    || $old->charged == 0;
 
   '';
 }
index 0f8ac97..6185fc4 100644 (file)
@@ -270,14 +270,17 @@ sub delete {
 
 }
 
-=item replace OLD_RECORD
+=item replace [ OLD_RECORD ]
 
 You can, but probably shouldn't modify credits... 
 
+Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not
+supplied, replaces this record.  If there is an error, returns the error,
+otherwise returns false.
+
 =cut
 
 sub replace {
-  #return "Can't modify credit!"
   my $self = shift;
   return "Can't modify closed credit" if $self->closed =~ /^Y/i;
   $self->SUPER::replace(@_);
index a33caf2..8a043ae 100644 (file)
@@ -1159,6 +1159,209 @@ sub delete {
 
 }
 
+=item merge NEW_CUSTNUM [ , OPTION => VALUE ... ]
+
+This merges this customer into the provided new custnum, and then deletes the
+customer.  If there is an error, returns the error, otherwise returns false.
+
+The source customer's name, company name, phone numbers, agent,
+referring customer, customer class, advertising source, order taker, and
+billing information (except balance) are discarded.
+
+All packages are moved to the target customer.  Packages with package locations
+are preserved.  Packages without package locations are moved to a new package
+location with the source customer's service/shipping address.
+
+All invoices, statements, payments, credits and refunds are moved to the target
+customer.  The source customer's balance is added to the target customer.
+
+All notes, attachments, tickets and customer tags are moved to the target
+customer.
+
+Change history is not currently moved.
+
+=cut
+
+sub merge {
+  my( $self, $new_custnum, %opt ) = @_;
+
+  return "Can't merge a customer into self" if $self->custnum == $new_custnum;
+
+  unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
+    return "Invalid new customer number: $new_custnum";
+  }
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  if ( qsearch('agent', { 'agent_custnum' => $self->custnum } ) ) {
+     $dbh->rollback if $oldAutoCommit;
+     return "Can't merge a master agent customer";
+  }
+
+  #use FS::access_user
+  if ( qsearch('access_user', { 'user_custnum' => $self->custnum } ) ) {
+     $dbh->rollback if $oldAutoCommit;
+     return "Can't merge a master employee customer";
+  }
+
+  if ( qsearch('cust_pay_pending', { 'custnum' => $self->custnum,
+                                     'status'  => { op=>'!=', value=>'done' },
+                                   }
+              )
+  ) {
+     $dbh->rollback if $oldAutoCommit;
+     return "Can't merge a customer with pending payments";
+  }
+
+  tie my %financial_tables, 'Tie::IxHash',
+    'cust_bill'      => 'invoices',
+    'cust_statement' => 'statements',
+    'cust_credit'    => 'credits',
+    'cust_pay'       => 'payments',
+    'cust_pay_void'  => 'voided payments',
+    'cust_refund'    => 'refunds',
+  ;
+   
+  foreach my $table ( keys %financial_tables ) {
+
+    my @records = $self->$table();
+
+    foreach my $record ( @records ) {
+      $record->custnum($new_custnum);
+      my $error = $record->replace;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "Error merging ". $financial_tables{$table}. ": $error\n";
+      }
+    }
+
+  }
+
+  my $locationnum = '';
+  foreach my $cust_pkg ( $self->all_pkgs ) {
+    $cust_pkg->custnum($new_custnum);
+
+    unless ( $cust_pkg->locationnum ) {
+      unless ( $locationnum ) {
+        my $cust_location = new FS::cust_location {
+          $self->location_hash,
+          'custnum' => $new_custnum,
+        };
+        my $error = $cust_location->insert;
+        if ( $error ) {
+          $dbh->rollback if $oldAutoCommit;
+          return $error;
+        }
+        $locationnum = $cust_location->locationnum;
+      }
+      $cust_pkg->locationnum($locationnum);
+    }
+
+    my $error = $cust_pkg->replace;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  #not considered:
+  # cust_tax_exempt (texas tax exemptions)
+  # cust_recon (some sort of not-well understood thing for OnPac)
+
+  #these are moved over
+  foreach my $table (qw(
+    cust_tag cust_location contact cust_attachment cust_main_note
+    cust_tax_adjustment cust_pay_batch queue
+  )) {
+    foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) {
+      $record->custnum($new_custnum);
+      my $error = $record->replace;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
+  }
+
+  #these aren't preserved
+  foreach my $table (qw(
+    cust_main_exemption cust_main_invoice
+  )) {
+    foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) {
+      my $error = $record->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
+  }
+
+
+  my $sth = $dbh->prepare(
+    'UPDATE cust_main SET referral_custnum = ? WHERE referral_custnum = ?'
+  ) or do {
+    my $errstr = $dbh->errstr;
+    $dbh->rollback if $oldAutoCommit;
+    return $errstr;
+  };
+  $sth->execute($new_custnum, $self->custnum) or do {
+    my $errstr = $sth->errstr;
+    $dbh->rollback if $oldAutoCommit;
+    return $errstr;
+  };
+
+  #tickets
+
+  my $ticket_dbh = '';
+  if ($conf->config('ticket_system') eq 'RT_Internal') {
+    $ticket_dbh = $dbh;
+  } elsif ($conf->config('ticket_system') eq 'RT_External') {
+    my ($datasrc, $user, $pass) = $conf->config('ticket_system-rt_external_datasrc');
+    $ticket_dbh = DBI->connect($datasrc, $user, $pass, { 'ChopBlanks' => 1 });
+      #or die "RT_External DBI->connect error: $DBI::errstr\n";
+  }
+
+  if ( $ticket_dbh ) {
+
+    my $ticket_sth = $ticket_dbh->prepare(
+      'UPDATE Links SET Target = ? WHERE Target = ?'
+    ) or do {
+      my $errstr = $ticket_dbh->errstr;
+      $dbh->rollback if $oldAutoCommit;
+      return $errstr;
+    };
+    $ticket_sth->execute('freeside://freeside/cust_main/'.$new_custnum,
+                         'freeside://freeside/cust_main/'.$self->custnum)
+      or do {
+        my $errstr = $ticket_sth->errstr;
+        $dbh->rollback if $oldAutoCommit;
+        return $errstr;
+      };
+
+  }
+
+  #delete the customer record
+
+  my $error = $self->delete;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
+
 =item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
 
 
index e0c99f8..5eb1d66 100644 (file)
@@ -401,14 +401,17 @@ sub delete {
 
 }
 
-=item replace OLD_RECORD
+=item replace [ OLD_RECORD ]
 
 You can, but probably shouldn't modify payments...
 
+Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not
+supplied, replaces this record.  If there is an error, returns the error,
+otherwise returns false.
+
 =cut
 
 sub replace {
-  #return "Can't modify payment!"
   my $self = shift;
   return "Can't modify closed payment" if $self->closed =~ /^Y/i;
   $self->SUPER::replace(@_);
index 9293ef6..45287a4 100644 (file)
@@ -155,16 +155,13 @@ sub unvoid {
 Deletes this voided payment.  You probably don't want to use this directly; see
 the B<unvoid> method to add the original payment back.
 
-=item replace OLD_RECORD
+=item replace [ OLD_RECORD ]
 
-Currently unimplemented.
+You can, but probably shouldn't modify voided payments...
 
-=cut
-
-sub replace {
-   return "Can't modify voided payments!" unless $otaker_upgrade_kludge;
-   shift->SUPER::replace(@_);
-}
+Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not
+supplied, replaces this record.  If there is an error, returns the error,
+otherwise returns false.
 
 =item check
 
index 4086f0f..7df7a55 100644 (file)
@@ -238,12 +238,17 @@ sub delete {
 
 =item replace OLD_RECORD
 
-Modifying a refund?  Well, don't say I didn't warn you.
+You can, but probably shouldn't modify refunds... 
+
+Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not
+supplied, replaces this record.  If there is an error, returns the error,
+otherwise returns false.
 
 =cut
 
 sub replace {
   my $self = shift;
+  return "Can't modify closed refund" if $self->closed =~ /^Y/i;
   $self->SUPER::replace(@_);
 }
 
index a786ae3..26bb7ca 100644 (file)
@@ -173,10 +173,15 @@ sub replace {
               ? shift
               : $self->replace_old;
 
-  my $options = 
-    ( ref($_[0]) eq 'HASH' )
-      ? shift
-      : { @_ };
+  my $options;
+  my $options_supplied = 0;
+  if ( ref($_[0]) eq 'HASH' ) {
+    $options = shift;
+    $options_supplied = 1;
+  } else {
+    $options = { @_ };
+    $options_supplied = scalar(@_) ? 1 : 0;
+  }
 
   warn "FS::option_Common::replace called on $self with options ".
        join(', ', map "$_ => ". $options->{$_}, keys %$options)
@@ -252,13 +257,15 @@ sub replace {
   }
 
   #remove extraneous old options
-  foreach my $opt (
-    grep { !exists $options->{$_->$namecol()} } $old->option_objects
-  ) {
-    my $error = $opt->delete;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
+  if ( $options_supplied ) {
+    foreach my $opt (
+      grep { !exists $options->{$_->$namecol()} } $old->option_objects
+    ) {
+      my $error = $opt->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
     }
   }
 
diff --git a/bin/merge-referrals b/bin/merge-referrals
new file mode 100644 (file)
index 0000000..ba07a81
--- /dev/null
@@ -0,0 +1,20 @@
+#!/usr/bin/perl
+
+use strict;
+use FS::UID qw(adminsuidsetup);
+use FS::Record qw(qsearchs);
+use FS::cust_main;
+
+my $user = shift or die "usage: merge-customers username custnum\n";
+adminsuidsetup $user;
+
+my $custnum = shift or die "usage: merge-customers username custnum\n";
+
+foreach my $cust_main (
+  qsearch('cust_main', { 'referral_custnum' => $custnum })
+) {
+  my $error = $cust_main->merge($custnum);
+  die $error if $error;
+}
+
+1;
index 317922d..e8c645e 100644 (file)
@@ -11,7 +11,7 @@ Example:
   );
 
 </%doc>
-<INPUT TYPE="hidden" NAME="<% $field %>" VALUE="<% $value %>">
+<INPUT TYPE="hidden" NAME="<% $field %>" ID="<% $field %>" VALUE="<% $value %>">
 
 <!-- some false laziness w/ misc/batch-cust_pay.html, though not as bad as i'd thought at first... -->
 
@@ -60,6 +60,9 @@ Example:
 
   function smart_<% $field %>_search(what) {
 
+    if ( <% $field %>_search_active )
+      return;
+
     var customer = what.value;
 
     if ( customer == 'searching...' || customer == ''
diff --git a/httemplate/misc/cust_main-merge.html b/httemplate/misc/cust_main-merge.html
new file mode 100755 (executable)
index 0000000..4decbef
--- /dev/null
@@ -0,0 +1,40 @@
+% if ( $error ) {
+% $cgi->param('error', $error);
+<% $cgi->redirect(popurl(1). "merge_cust.html?". $cgi->query_string ) %>
+% } else {
+<% include('/elements/header-popup.html', "Customer merged") %>
+  <SCRIPT TYPE="text/javascript">
+    window.top.location.href = '<% $p %>view/cust_main.cgi?<% $new_custnum %>';
+%# parent.nd(1) ?
+  </SCRIPT>
+  </BODY>
+</HTML>
+% }
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Merge customer');
+
+my $error = '';
+
+$cgi->param('custnum') =~ /^(\d+)$/ or die "illegal custnum";
+my $custnum = $1;
+
+my $new_custnum;
+if ( $cgi->param('new_custnum') =~ /^(\d+)$/ ) {
+  $new_custnum = $1;
+
+  my $cust_main = qsearchs( {
+    'table'     => 'cust_main',
+    'hashref'   => { 'custnum' => $custnum },
+    'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+  } );
+  die "No customer # $custnum" unless $cust_main;
+
+  $error = $cust_main->merge($new_custnum);
+
+} else {
+  $error = 'Select a customer to merge into';
+}
+
+</%init>
diff --git a/httemplate/misc/merge_cust.html b/httemplate/misc/merge_cust.html
new file mode 100644 (file)
index 0000000..ad075be
--- /dev/null
@@ -0,0 +1,72 @@
+<% include('/elements/header-popup.html', 'Merge customer' ) %>
+
+<% include('/elements/error.html') %>
+
+<FORM NAME="cust_merge_popup" ID="cust_merge_popup" ACTION="<% popurl(1) %>cust_main-merge.html" METHOD=POST onSubmit="submit_merge(); return false;">
+
+<SCRIPT TYPE="text/javascript">
+
+var submit_interval_id;
+function submit_merge() {
+  document.getElementById('confirm_merge_cust_button').disabled = 'true';
+  smart_new_custnum_search(document.getElementById('new_custnum_search'));
+  submit_interval_id = setInterval( do_submit_merge, 100);
+}
+
+function do_submit_merge() {
+
+  if ( new_custnum_search_active )
+    return;
+
+  document.getElementById('confirm_merge_cust_button').disabled = '';
+
+  clearInterval(submit_interval_id);
+
+  if ( document.cust_merge_popup.new_custnum.value != '' ) {
+    document.cust_merge_popup.submit();
+  }
+
+}
+
+</SCRIPT>
+
+</SCRIPT>
+
+<INPUT TYPE="hidden" NAME="custnum" VALUE="<% $custnum %>">
+
+<TABLE BORDER="0" CELLSPACING="2" STYLE="margin-left:auto; margin-right:auto">
+  <% include('/elements/tr-search-cust_main.html',
+               'label'       => 'Merge into: ',
+               'field'       => 'new_custnum',
+               'find_button' => 1,
+               'curr_value'  => scalar($cgi->param('new_custnum')),
+            )
+  %>
+</TABLE>
+
+<P ALIGN="CENTER">
+%#have merge button start out disabled and enable after you select a target cust
+<INPUT TYPE="submit" NAME="confirm_merge_cust_button" ID="confirm_merge_cust_button" VALUE="Merge customer">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<INPUT TYPE="BUTTON" VALUE="Don't merge" onClick="parent.cClick();"> 
+
+</FORM>
+</BODY>
+</HTML>
+
+<%init>
+
+$cgi->param('custnum') =~ /^(\d+)$/ or die 'illegal custnum';
+my $custnum = $1;
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied" unless $curuser->access_right('Merge customer');
+
+my $cust_main = qsearchs( {
+  'table'     => 'cust_main',
+  'hashref'   => { 'custnum' => $custnum },
+  'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+} );
+die "No customer # $custnum" unless $cust_main;
+
+</%init>
+
index b4a6170..0f9c1e2 100755 (executable)
@@ -64,6 +64,22 @@ function areyousure(href, message) {
 
 % }
 
+% if ( $curuser->access_right('Merge customer') ) {
+
+  <% include( '/elements/popup_link-cust_main.html',
+              { 'action'      => $p. 'misc/merge_cust.html',
+                'label'       => 'Merge&nbsp;this&nbsp;customer',
+                'actionlabel' => 'Merge customer',
+                #'color'       => '#ff0000',
+                'cust_main'   => $cust_main,
+                'width'       => 480,
+                'height'      => 192,
+              }
+            )
+  %> | 
+
+% } 
+
 % if ( $conf->exists('deletecustomers')
 %        && $curuser->access_right('Delete customer')
 %      ) {