From a8e4b1744a6bbd2e1509b58e73b1d52751563880 Mon Sep 17 00:00:00 2001 From: ivan Date: Wed, 20 Oct 2010 02:07:02 +0000 Subject: [PATCH] customer merging, RT#10247 --- FS/FS/AccessRight.pm | 2 + FS/FS/Conf.pm | 2 +- FS/FS/cust_bill.pm | 18 +-- FS/FS/cust_credit.pm | 7 +- FS/FS/cust_main.pm | 203 ++++++++++++++++++++++++++++++ FS/FS/cust_pay.pm | 7 +- FS/FS/cust_pay_void.pm | 13 +- FS/FS/cust_refund.pm | 7 +- FS/FS/option_Common.pm | 29 +++-- bin/merge-referrals | 20 +++ httemplate/elements/search-cust_main.html | 5 +- httemplate/misc/cust_main-merge.html | 40 ++++++ httemplate/misc/merge_cust.html | 72 +++++++++++ httemplate/view/cust_main.cgi | 16 +++ 14 files changed, 406 insertions(+), 35 deletions(-) create mode 100644 bin/merge-referrals create mode 100755 httemplate/misc/cust_main-merge.html create mode 100644 httemplate/misc/merge_cust.html diff --git a/FS/FS/AccessRight.pm b/FS/FS/AccessRight.pm index 92c4d2299..6c06ec2f3 100644 --- a/FS/FS/AccessRight.pm +++ b/FS/FS/AccessRight.pm @@ -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'; diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index 42efa793a..980bd628a 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -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', }, diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index d9c2c40e2..0cf052682 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -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). +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). 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; ''; } diff --git a/FS/FS/cust_credit.pm b/FS/FS/cust_credit.pm index 0f8ac9703..6185fc472 100644 --- a/FS/FS/cust_credit.pm +++ b/FS/FS/cust_credit.pm @@ -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(@_); diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index a33caf21f..8a043ae14 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -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 ... ] ] diff --git a/FS/FS/cust_pay.pm b/FS/FS/cust_pay.pm index e0c99f898..5eb1d662e 100644 --- a/FS/FS/cust_pay.pm +++ b/FS/FS/cust_pay.pm @@ -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(@_); diff --git a/FS/FS/cust_pay_void.pm b/FS/FS/cust_pay_void.pm index 9293ef6d7..45287a41b 100644 --- a/FS/FS/cust_pay_void.pm +++ b/FS/FS/cust_pay_void.pm @@ -155,16 +155,13 @@ sub unvoid { Deletes this voided payment. You probably don't want to use this directly; see the B 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 diff --git a/FS/FS/cust_refund.pm b/FS/FS/cust_refund.pm index 4086f0f95..7df7a557a 100644 --- a/FS/FS/cust_refund.pm +++ b/FS/FS/cust_refund.pm @@ -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(@_); } diff --git a/FS/FS/option_Common.pm b/FS/FS/option_Common.pm index a786ae3fa..26bb7caef 100644 --- a/FS/FS/option_Common.pm +++ b/FS/FS/option_Common.pm @@ -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 index 000000000..ba07a81c6 --- /dev/null +++ b/bin/merge-referrals @@ -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; diff --git a/httemplate/elements/search-cust_main.html b/httemplate/elements/search-cust_main.html index 317922d3c..e8c645eca 100644 --- a/httemplate/elements/search-cust_main.html +++ b/httemplate/elements/search-cust_main.html @@ -11,7 +11,7 @@ Example: ); - + @@ -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 index 000000000..4decbef7a --- /dev/null +++ b/httemplate/misc/cust_main-merge.html @@ -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") %> + + + +% } +<%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'; +} + + diff --git a/httemplate/misc/merge_cust.html b/httemplate/misc/merge_cust.html new file mode 100644 index 000000000..ad075be2f --- /dev/null +++ b/httemplate/misc/merge_cust.html @@ -0,0 +1,72 @@ +<% include('/elements/header-popup.html', 'Merge customer' ) %> + +<% include('/elements/error.html') %> + +
+ + + + + + + + + <% include('/elements/tr-search-cust_main.html', + 'label' => 'Merge into: ', + 'field' => 'new_custnum', + 'find_button' => 1, + 'curr_value' => scalar($cgi->param('new_custnum')), + ) + %> +
+ +

+%#have merge button start out disabled and enable after you select a target cust +         + +

+ + + +<%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; + + + diff --git a/httemplate/view/cust_main.cgi b/httemplate/view/cust_main.cgi index b4a6170c5..0f9c1e250 100755 --- a/httemplate/view/cust_main.cgi +++ b/httemplate/view/cust_main.cgi @@ -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 this customer', + 'actionlabel' => 'Merge customer', + #'color' => '#ff0000', + 'cust_main' => $cust_main, + 'width' => 480, + 'height' => 192, + } + ) + %> | + +% } + % if ( $conf->exists('deletecustomers') % && $curuser->access_right('Delete customer') % ) { -- 2.11.0