From 167dbdad01e2c1b62fd9be43cc05212e8c874a02 Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Tue, 3 Feb 2015 07:14:45 -0800 Subject: [PATCH] contacts can be shared among customers / "duplicate contact emails", RT#27943 --- FS/FS/ClientAPI/MyAccount.pm | 70 ++++- FS/FS/ClientAPI_XMLRPC.pm | 1 + FS/FS/Conf.pm | 2 +- FS/FS/Mason.pm | 3 + FS/FS/Schema.pm | 59 +++- FS/FS/Upgrade.pm | 3 + FS/FS/contact.pm | 314 ++++++++++++++++++--- FS/FS/cust_contact.pm | 146 ++++++++++ FS/FS/cust_main.pm | 67 ++++- FS/FS/msg_template.pm | 40 +-- FS/FS/o2m_Common.pm | 7 +- FS/FS/prospect_contact.pm | 125 ++++++++ FS/FS/prospect_main.pm | 4 +- FS/MANIFEST | 4 + FS/t/cust_contact.t | 5 + FS/t/prospect_contact.t | 5 + fs_selfservice/FS-SelfService/SelfService.pm | 1 + fs_selfservice/FS-SelfService/cgi/select_cust.html | 38 +++ fs_selfservice/FS-SelfService/cgi/selfservice.cgi | 16 +- httemplate/edit/cust_main.cgi | 4 +- httemplate/edit/elements/edit.html | 4 + httemplate/elements/contact.html | 19 +- httemplate/elements/tr-select-contact.html | 10 +- httemplate/misc/email-quotation.html | 8 +- httemplate/search/contact.html | 60 ++-- httemplate/search/prospect_main.html | 4 +- httemplate/view/cust_main/contacts_new.html | 33 ++- httemplate/view/prospect_main.html | 5 +- 28 files changed, 915 insertions(+), 142 deletions(-) create mode 100644 FS/FS/cust_contact.pm create mode 100644 FS/FS/prospect_contact.pm create mode 100644 FS/t/cust_contact.t create mode 100644 FS/t/prospect_contact.t create mode 100644 fs_selfservice/FS-SelfService/cgi/select_cust.html diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm index 8276d7e4b..86c7ac324 100644 --- a/FS/FS/ClientAPI/MyAccount.pm +++ b/FS/FS/ClientAPI/MyAccount.pm @@ -46,6 +46,7 @@ use FS::payby; use FS::acct_rt_transaction; use FS::msg_template; use FS::contact; +use FS::cust_contact; $DEBUG = 1; $me = '[FS::ClientAPI::MyAccount]'; @@ -82,7 +83,7 @@ sub skin_info { #return { 'error' => $session } if $context eq 'error'; my $agentnum = ''; - if ( $context eq 'customer' ) { + if ( $context eq 'customer' && $custnum ) { my $sth = dbh->prepare('SELECT agentnum FROM cust_main WHERE custnum = ?') or die dbh->errstr; @@ -237,7 +238,16 @@ sub login { return { error => 'Incorrect contact password.' } unless $contact->authenticate_password($p->{'password'}); - $session->{'custnum'} = $contact->custnum; + my @cust_contact = grep $_->selfservice_access, $contact->cust_contact; + if ( scalar(@cust_contact) == 1 ) { + $session->{'custnum'} = $cust_contact[0]->custnum; + } elsif ( scalar(@cust_contact) ) { + $session->{'customers'} = { map { $_->custnum => $_->cust_main->name } + @cust_contact + }; + } else { + return { error => 'No customer self-service access for contact' }; #?? + } } else { @@ -303,6 +313,7 @@ sub login { return { 'error' => '', 'session_id' => $session_id, + %$session, }; } @@ -336,6 +347,23 @@ sub switch_acct { } +sub switch_cust { + my $p = shift; + my($context, $session, $custnum) = _custoragent_session_custnum($p); + return { 'error' => $session } if $context eq 'error'; + + $session->{'custnum'} = $p->{'custnum'} + if exists $session->{'customers'}{ $p->{'custnum'} }; + + my $conf = new FS::Conf; + my $timeout = $conf->config('selfservice-session_timeout') || '1 hour'; + _cache->set( $p->{'session_id'}, $session, $timeout ); + + return { 'error' => '', + %{ customer_info( { session_id=>$p->{'session_id'} } ) }, + }; +} + sub payment_gateway { # internal use only # takes a cust_main and a cust_payby entry, returns the payment_gateway @@ -380,22 +408,23 @@ sub access_info { my($context, $session, $custnum) = _custoragent_session_custnum($p); return { 'error' => $session } if $context eq 'error'; - my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } ) - or return { 'error' => "unknown custnum $custnum" }; + my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } ); $info->{'hide_payment_fields'} = [ map { - my $pg = payment_gateway($cust_main, $_); + my $pg = $cust_main && payment_gateway($cust_main, $_); $pg && $pg->gateway_namespace eq 'Business::OnlineThirdPartyPayment'; } @{ $info->{cust_paybys} } ]; $info->{'self_suspend_reason'} = - $conf->config('selfservice-self_suspend_reason', $cust_main->agentnum); + $conf->config('selfservice-self_suspend_reason', + $cust_main ? $cust_main->agentnum : '' + ); $info->{'edit_ticket_subject'} = $conf->exists('ticket_system-selfservice_edit_subject') && - $cust_main->edit_subject; + $cust_main && $cust_main->edit_subject; $info->{'timeout'} = $conf->config('selfservice-timeout') || 3600; @@ -432,7 +461,7 @@ sub customer_info { my $search = { 'custnum' => $custnum }; $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent'; my $cust_main = qsearchs('cust_main', $search ) - or return { 'error' => "unknown custnum $custnum" }; + or return { 'error' => "customer_info: unknown custnum $custnum" }; my $list_tickets = list_tickets($p); $return{'tickets'} = $list_tickets->{'tickets'}; @@ -536,7 +565,7 @@ sub customer_info_short { my $search = { 'custnum' => $custnum }; $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent'; my $cust_main = qsearchs('cust_main', $search ) - or return { 'error' => "unknown custnum $custnum" }; + or return { 'error' => "customer_info_short: unknown custnum $custnum" }; $return{display_custnum} = $cust_main->display_custnum; @@ -2916,7 +2945,12 @@ sub myaccount_passwd { #need to support the "ISP provides email that's used as a contact email" case #as well as we can. my $contact = FS::contact->by_selfservice_email($svc_acct->email); - if ( $contact && $contact->custnum == $custnum ) { + if ( $contact && qsearchs('cust_contact', { contactnum=> $contact->contactnum, + custnum => $custnum, + selfservice_access => 'Y', + } + ) + ) { #svc_acct was successful but this one returns an error? "shouldn't happen" $error ||= $contact->change_password($p->{'new_password'}); } @@ -2993,7 +3027,10 @@ sub reset_passwd { $contact = FS::contact->by_selfservice_email($p->{'email'}); - $cust_main = $contact->cust_main if $contact; + if ( $contact ) { + my @cust_contact = grep $_->selfservice_access, $contact->cust_contact; + $cust_main = $cust_contact[0]->cust_main if scalar(@cust_contact) == 1; + } #also look for an svc_acct, otherwise it would be super confusing @@ -3035,6 +3072,9 @@ sub reset_passwd { } + return { %$info, 'error' => 'Multi-customer contacts incompatible with customer-based verification' } + if ! $cust_main && $verification ne 'email'; + my %verify = ( 'email' => sub { 1; }, 'paymask' => sub { @@ -3157,7 +3197,9 @@ sub check_reset_passwd { my @contact_email = $contact->contact_email; return { 'error' => 'No contact email' } unless @contact_email; - $p->{'agentnum'} = $contact->cust_main->agentnum; + my @cust_contact = grep $_->selfservice_access, $contact->cust_contact; + $p->{'agentnum'} = $cust_contact[0]->cust_main->agentnum + if scalar(@cust_contact) == 1; my $info = skin_info($p); return { %$info, @@ -3207,7 +3249,9 @@ sub process_reset_passwd { $contact = qsearchs('contact', { 'contactnum' => $contactnum } ) or return { 'error' => "Contact not found" }; - $p->{'agentnum'} ||= $contact->cust_main->agentnum; + my @cust_contact = grep $_->selfservice_access, $contact->cust_contact; + $p->{'agentnum'} = $cust_contact[0]->cust_main->agentnum + if scalar(@cust_contact) == 1; $info ||= skin_info($p); } diff --git a/FS/FS/ClientAPI_XMLRPC.pm b/FS/FS/ClientAPI_XMLRPC.pm index 62f61d6e5..952b19940 100644 --- a/FS/FS/ClientAPI_XMLRPC.pm +++ b/FS/FS/ClientAPI_XMLRPC.pm @@ -102,6 +102,7 @@ sub ss2clientapi { 'login' => 'MyAccount/login', 'logout' => 'MyAccount/logout', 'switch_acct' => 'MyAccount/switch_acct', + 'switch_cust' => 'MyAccount/switch_cust', 'customer_info' => 'MyAccount/customer_info', 'customer_info_short' => 'MyAccount/customer_info_short', 'billing_history' => 'MyAccount/billing_history', diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index 029f1a1bf..2b959e661 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -2996,7 +2996,7 @@ and customer address. Include units.', 'type' => 'select', 'select_hash' => [ '' => 'Password reset disabled', 'email' => 'Click on a link in email', - 'paymask,amount,zip' => 'Click on a link in email, and also verify with credit card (or bank account) last 4 digits, payment amount and zip code', + 'paymask,amount,zip' => 'Click on a link in email, and also verify with credit card (or bank account) last 4 digits, payment amount and zip code. Note: Do not use if you have multi-customer contacts, as they will be unable to reset their passwords.', ], }, diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm index d3e45dfee..37e3ad243 100644 --- a/FS/FS/Mason.pm +++ b/FS/FS/Mason.pm @@ -396,6 +396,9 @@ if ( -e $addl_handler_use_file ) { use FS::circuit_provider; use FS::circuit_termination; use FS::svc_circuit; + use FS::cust_credit_source_bill_pkg; + use FS::prospect_contact; + use FS::cust_contact; # Sammath Naur if ( $FS::Mason::addl_handler_use ) { diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index d5ed1b718..133b6d81a 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -1740,20 +1740,69 @@ sub tables_hashref { 'index' => [ ['disabled'] ], }, + 'cust_contact' => { + 'columns' => [ + 'custcontactnum', 'serial', '', '', '', '', + 'custnum', 'int', '', '', '', '', + 'contactnum', 'int', '', '', '', '', + 'classnum', 'int', 'NULL', '', '', '', + 'comment', 'varchar', 'NULL', 255, '', '', + 'selfservice_access', 'char', 'NULL', 1, '', '', + ], + 'primary_key' => 'custcontactnum', + 'unique' => [ [ 'custnum', 'contactnum' ], ], + 'index' => [ [ 'custnum' ], [ 'contactnum' ], ], + 'foreign_keys' => [ + { columns => [ 'custnum' ], + table => 'cust_main', + }, + { columns => [ 'contactnum' ], + table => 'contact', + }, + { columns => [ 'classnum' ], + table => 'contact_class', + }, + ], + }, + + 'prospect_contact' => { + 'columns' => [ + 'prospectcontactnum', 'serial', '', '', '', '', + 'prospectnum', 'int', '', '', '', '', + 'contactnum', 'int', '', '', '', '', + 'classnum', 'int', 'NULL', '', '', '', + 'comment', 'varchar', 'NULL', 255, '', '', + ], + 'primary_key' => 'prospectcontactnum', + 'unique' => [ [ 'prospectnum', 'contactnum' ], ], + 'index' => [ [ 'prospectnum' ], [ 'contactnum' ], ], + 'foreign_keys' => [ + { columns => [ 'prospectnum' ], + table => 'prospect_main', + }, + { columns => [ 'contactnum' ], + table => 'contact', + }, + { columns => [ 'classnum' ], + table => 'contact_class', + }, + ], + }, + 'contact' => { 'columns' => [ 'contactnum', 'serial', '', '', '', '', - 'prospectnum', 'int', 'NULL', '', '', '', - 'custnum', 'int', 'NULL', '', '', '', + 'prospectnum', 'int', 'NULL', '', '', '', #deprecated, now prospect_contact table + 'custnum', 'int', 'NULL', '', '', '', #deprecated, now cust_contact table 'locationnum', 'int', 'NULL', '', '', '', #not yet - 'classnum', 'int', 'NULL', '', '', '', + 'classnum', 'int', 'NULL', '', '', '', #deprecated, now prospect_contact or cust_contact # 'titlenum', 'int', 'NULL', '', '', '', #eg Mr. Mrs. Dr. Rev. 'last', 'varchar', '', $char_d, '', '', # 'middle', 'varchar', 'NULL', $char_d, '', '', 'first', 'varchar', '', $char_d, '', '', 'title', 'varchar', 'NULL', $char_d, '', '', #eg Head Bottle Washer - 'comment', 'varchar', 'NULL', 255, '', '', - 'selfservice_access', 'char', 'NULL', 1, '', '', + 'comment', 'varchar', 'NULL', 255, '', '', #depredated, now prospect_contact or cust_contact + 'selfservice_access', 'char', 'NULL', 1, '', '', #deprecated, now cust_contact '_password', 'varchar', 'NULL', $char_d, '', '', '_password_encoding', 'varchar', 'NULL', $char_d, '', '', 'disabled', 'char', 'NULL', 1, '', '', diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm index 4719caa22..d05b309c7 100644 --- a/FS/FS/Upgrade.pm +++ b/FS/FS/Upgrade.pm @@ -312,6 +312,9 @@ sub upgrade_data { #cust_main (remove paycvv from history) 'cust_main' => [], + #contact -> cust_contact / prospect_contact + 'contact' => [], + #msgcat 'msgcat' => [], diff --git a/FS/FS/contact.pm b/FS/FS/contact.pm index 3205df106..07458c742 100644 --- a/FS/FS/contact.pm +++ b/FS/FS/contact.pm @@ -3,12 +3,15 @@ use base qw( FS::Record ); use strict; use vars qw( $skip_fuzzyfiles ); +use Carp; use Scalar::Util qw( blessed ); use FS::Record qw( qsearch qsearchs dbh ); use FS::contact_phone; use FS::contact_email; use FS::queue; use FS::phone_type; #for cgi_contact_fields +use FS::cust_contact; +use FS::prospect_contact; $skip_fuzzyfiles = 0; @@ -123,10 +126,88 @@ sub insert { local $FS::UID::AutoCommit = 0; my $dbh = dbh; - my $error = $self->SUPER::insert; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return $error; + #save off and blank values that move to cust_contact / prospect_contact now + my $prospectnum = $self->prospectnum; + $self->prospectnum(''); + my $custnum = $self->custnum; + $self->custnum(''); + + my %link_hash = (); + for (qw( classnum comment selfservice_access )) { + $link_hash{$_} = $self->get($_); + $self->$_(''); + } + + #look for an existing contact with this email address + my $existing_contact = ''; + if ( $self->get('emailaddress') =~ /\S/ ) { + + my %existing_contact = (); + + foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) { + + my $contact_email = qsearchs('contact_email', { emailaddress=>$email } ) + or next; + + my $contact = $contact_email->contact; + $existing_contact{ $contact->contactnum } = $contact; + + } + + if ( scalar( keys %existing_contact ) > 1 ) { + $dbh->rollback if $oldAutoCommit; + return 'Multiple email addresses specified '. + ' that already belong to separate contacts'; + } elsif ( scalar( keys %existing_contact ) ) { + ($existing_contact) = values %existing_contact; + } + + } + + if ( $existing_contact ) { + + $self->$_($existing_contact->$_()) + for qw( contactnum _password _password_encoding ); + $self->SUPER::replace($existing_contact); + + } else { + + my $error = $self->SUPER::insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + + } + + my $cust_contact = ''; + if ( $custnum ) { + my %hash = ( 'contactnum' => $self->contactnum, + 'custnum' => $custnum, + ); + $cust_contact = qsearchs('cust_contact', \%hash ) + || new FS::cust_contact { %hash, %link_hash }; + my $error = $cust_contact->custcontactnum ? $cust_contact->replace + : $cust_contact->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + if ( $prospectnum ) { + my %hash = ( 'contactnum' => $self->contactnum, + 'prospectnum' => $prospectnum, + ); + my $prospect_contact = qsearchs('prospect_contact', \%hash ) + || new FS::prospect_contact { %hash, %link_hash }; + my $error = + $prospect_contact->prospectcontactnum ? $prospect_contact->replace + : $prospect_contact->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } } foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) =~ /\S/ } @@ -134,12 +215,14 @@ sub insert { $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)"; my $phonetypenum = $1; - my $contact_phone = new FS::contact_phone { - 'contactnum' => $self->contactnum, - 'phonetypenum' => $phonetypenum, - _parse_phonestring( $self->get($pf) ), - }; - $error = $contact_phone->insert; + my %hash = ( 'contactnum' => $self->contactnum, + 'phonetypenum' => $phonetypenum, + ); + my $contact_phone = + qsearchs('contact_phone', \%hash) + || new FS::contact_phone { %hash, _parse_phonestring($self->get($pf)) }; + my $error = $contact_phone->contactphonenum ? $contact_phone->replace + : $contact_phone->insert; if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error; @@ -149,17 +232,18 @@ sub insert { if ( $self->get('emailaddress') =~ /\S/ ) { foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) { - - my $contact_email = new FS::contact_email { + my %hash = ( 'contactnum' => $self->contactnum, 'emailaddress' => $email, - }; - $error = $contact_email->insert; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return $error; + ); + unless ( qsearchs('contact_email', \%hash) ) { + my $contact_email = new FS::contact_email \%hash; + my $error = $contact_email->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } } - } } @@ -167,14 +251,17 @@ sub insert { unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) { #warn " queueing fuzzyfiles update\n" # if $DEBUG > 1; - $error = $self->queue_fuzzyfiles_update; + my $error = $self->queue_fuzzyfiles_update; if ( $error ) { $dbh->rollback if $oldAutoCommit; return "updating fuzzy search cache: $error"; } } - if ( $self->selfservice_access ) { + if ( $link_hash{'selfservice_access'} eq 'R' + or ( $link_hash{'selfservice_access'} && $cust_contact ) + ) + { my $error = $self->send_reset_email( queue=>1 ); if ( $error ) { $dbh->rollback if $oldAutoCommit; @@ -208,6 +295,44 @@ sub delete { local $FS::UID::AutoCommit = 0; my $dbh = dbh; + #got a prospetnum or custnum? delete the prospect_contact or cust_contact link + + if ( $self->prospectnum ) { + my $prospect_contact = qsearchs('prospect_contact', { + 'contactnum' => $self->contactnum, + 'prospectnum' => $self->prospectnum, + }); + my $error = $prospect_contact->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + if ( $self->custnum ) { + my $cust_contact = qsearchs('cust_contact', { + 'contactnum' => $self->contactnum, + 'custnum' => $self->custnum, + }); + my $error = $cust_contact->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + # then, proceed with deletion only if the contact isn't attached to any other + # prospects or customers + + #inefficient, but how many prospects/customers can a single contact be + # attached too? (and is removing them from one a common operation?) + if ( $self->prospect_contact || $self->cust_contact ) { + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + return ''; + } + + #proceed with deletion + foreach my $cust_pkg ( $self->cust_pkg ) { $cust_pkg->contactnum(''); my $error = $cust_pkg->replace; @@ -262,13 +387,62 @@ sub replace { local $FS::UID::AutoCommit = 0; my $dbh = dbh; + #save off and blank values that move to cust_contact / prospect_contact now + my $prospectnum = $self->prospectnum; + $self->prospectnum(''); + my $custnum = $self->custnum; + $self->custnum(''); + + my %link_hash = (); + for (qw( classnum comment selfservice_access )) { + $link_hash{$_} = $self->get($_); + $self->$_(''); + } + my $error = $self->SUPER::replace($old); if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error; } - foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) } + my $cust_contact = ''; + if ( $custnum ) { + my %hash = ( 'contactnum' => $self->contactnum, + 'custnum' => $custnum, + ); + my $error; + if ( $cust_contact = qsearchs('cust_contact', \%hash ) ) { + $cust_contact->$_($link_hash{$_}) for keys %link_hash; + $error = $cust_contact->replace; + } else { + $cust_contact = new FS::cust_contact { %hash, %link_hash }; + $error = $cust_contact->insert; + } + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + if ( $prospectnum ) { + my %hash = ( 'contactnum' => $self->contactnum, + 'prospectnum' => $prospectnum, + ); + my $error; + if ( my $prospect_contact = qsearchs('prospect_contact', \%hash ) ) { + $prospect_contact->$_($link_hash{$_}) for keys %link_hash; + $error = $prospect_contact->replace; + } else { + my $prospect_contact = new FS::prospect_contact { %hash, %link_hash }; + $error = $prospect_contact->insert; + } + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + foreach my $pf ( grep { /^phonetypenum(\d+)$/ } keys %{ $self->hashref } ) { $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)"; my $phonetypenum = $1; @@ -276,8 +450,19 @@ sub replace { my %cp = ( 'contactnum' => $self->contactnum, 'phonetypenum' => $phonetypenum, ); - my $contact_phone = qsearchs('contact_phone', \%cp) - || new FS::contact_phone \%cp; + my $contact_phone = qsearchs('contact_phone', \%cp); + + #if new value is empty, delete old entry + if (!$self->get($pf)) { + if ($contact_phone) { + $error = $contact_phone->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + next; + } my %cpd = _parse_phonestring( $self->get($pf) ); $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd; @@ -329,11 +514,14 @@ sub replace { } } - if ( ( $old->selfservice_access eq '' && $self->selfservice_access - && ! $self->_password - ) - || $self->_resend() - ) + if ( $cust_contact and ( + ( $cust_contact->selfservice_access eq '' + && $link_hash{selfservice_access} + && ! length($self->_password) + ) + || $cust_contact->_resend() + ) + ) { my $error = $self->send_reset_email( queue=>1 ); if ( $error ) { @@ -450,7 +638,6 @@ sub check { ; return $error if $error; - return "No prospect or customer!" unless $self->prospectnum || $self->custnum; return "Prospect and customer!" if $self->prospectnum && $self->custnum; return "One of first name, last name, or title must have a value" @@ -487,17 +674,35 @@ sub firstlast { $self->first . ' ' . $self->last; } -=item contact_classname - -Returns the name of this contact's class (see L). - -=cut - -sub contact_classname { - my $self = shift; - my $contact_class = $self->contact_class or return ''; - $contact_class->classname; -} +#=item contact_classname PROSPECT_OBJ | CUST_MAIN_OBJ +# +#Returns the name of this contact's class for the specified prospect or +#customer (see L, L and +#L). +# +#=cut +# +#sub contact_classname { +# my( $self, $prospect_or_cust ) = @_; +# +# my $link = ''; +# if ( ref($prospect_or_cust) eq 'FS::prospect_main' ) { +# $link = qsearchs('prospect_contact', { +# 'contactnum' => $self->contactnum, +# 'prospectnum' => $prospect_or_cust->prospectnum, +# }); +# } elsif ( ref($prospect_or_cust) eq 'FS::cust_main' ) { +# $link = qsearchs('cust_contact', { +# 'contactnum' => $self->contactnum, +# 'custnum' => $prospect_or_cust->custnum, +# }); +# } else { +# croak "$prospect_or_cust is not an FS::prospect_main or FS::cust_main object"; +# } +# +# my $contact_class = $link->contact_class or return ''; +# $contact_class->classname; +#} =item by_selfservice_email EMAILADDRESS @@ -514,8 +719,7 @@ sub by_selfservice_email { 'table' => 'contact_email', 'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ', 'hashref' => { 'emailaddress' => $email, }, - 'extra_sql' => " AND selfservice_access = 'Y' ". - " AND ( disabled IS NULL OR disabled = '' )", + 'extra_sql' => " AND ( disabled IS NULL OR disabled = '' )", }) or return ''; $contact_email->contact; @@ -616,10 +820,12 @@ sub send_reset_email { my $conf = new FS::Conf; - my $cust_main = $self->cust_main - or die "no customer"; #reset a password for a prospect contact? someday + my $cust_main = ''; + my @cust_contact = grep $_->selfservice_access, $self->cust_contact; + $cust_main = $cust_contact[0]->cust_main if scalar(@cust_contact) == 1; - my $msgnum = $conf->config('selfservice-password_reset_msgnum', $cust_main->agentnum); + my $agentnum = $cust_main ? $cust_main->agentnum : ''; + my $msgnum = $conf->config('selfservice-password_reset_msgnum', $agentnum); #die "selfservice-password_reset_msgnum unset" unless $msgnum; return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum; my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } ); @@ -634,7 +840,7 @@ sub send_reset_email { my $queue = new FS::queue { 'job' => 'FS::Misc::process_send_email', - 'custnum' => $cust_main->custnum, + 'custnum' => $cust_main ? $cust_main->custnum : '', }; $queue->insert( $msg_template->prepare( %msg_template ) ); @@ -677,7 +883,21 @@ sub cgi_contact_fields { } -use FS::phone_type; +use FS::upgrade_journal; +sub _upgrade_data { #class method + my ($class, %opts) = @_; + + unless ( FS::upgrade_journal->is_done('contact__DUPEMAIL') ) { + + foreach my $contact (qsearch('contact', {})) { + my $error = $contact->replace; + die $error if $error; + } + + FS::upgrade_journal->set_done('contact__DUPEMAIL'); + } + +} =back diff --git a/FS/FS/cust_contact.pm b/FS/FS/cust_contact.pm new file mode 100644 index 000000000..6f899d83f --- /dev/null +++ b/FS/FS/cust_contact.pm @@ -0,0 +1,146 @@ +package FS::cust_contact; +use base qw( FS::Record ); + +use strict; +use FS::Record qw( qsearch qsearchs ); + +=head1 NAME + +FS::cust_contact - Object methods for cust_contact records + +=head1 SYNOPSIS + + use FS::cust_contact; + + $record = new FS::cust_contact \%hash; + $record = new FS::cust_contact { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::cust_contact object represents a contact's attachment to a specific +customer. FS::cust_contact inherits from FS::Record. The following fields are +currently supported: + +=over 4 + +=item custcontactnum + +primary key + +=item custnum + +custnum + +=item contactnum + +contactnum + +=item classnum + +classnum + +=item comment + +comment + +=item selfservice_access + +empty or Y + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new record. To add the record to the database, see L<"insert">. + +Note that this stores the hash reference, not a distinct copy of the hash it +points to. You can ask the object for a copy with the I method. + +=cut + +# the new method can be inherited from FS::Record, if a table method is defined + +sub table { 'cust_contact'; } + +=item insert + +Adds this record to the database. If there is an error, returns the error, +otherwise returns false. + +=item delete + +Delete this record from the database. + +=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. + +=item check + +Checks all fields to make sure this is a valid record. If there is +an error, returns the error, otherwise returns false. Called by the insert +and replace methods. + +=cut + +# the check method should currently be supplied - FS::Record contains some +# data checking routines + +sub check { + my $self = shift; + + if ( $self->selfservice_access eq 'R' ) { + $self->selfservice_access('Y'); + $self->_resend('Y'); + } + + my $error = + $self->ut_numbern('custcontactnum') + || $self->ut_number('custnum') + || $self->ut_number('contactnum') + || $self->ut_numbern('classnum') + || $self->ut_textn('comment') + || $self->ut_enum('selfservice_access', [ '', 'Y' ]) + ; + return $error if $error; + + $self->SUPER::check; +} + +=item contact_classname + +Returns the name of this contact's class (see L). + +=cut + +sub contact_classname { + my $self = shift; + my $contact_class = $self->contact_class or return ''; + $contact_class->classname; +} + +=back + +=head1 BUGS + +=head1 SEE ALSO + +L, L, L + +=cut + +1; + diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index d6f1a3176..cd675f9d4 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -71,7 +71,7 @@ use FS::agent_payment_gateway; use FS::banned_pay; use FS::cust_main_note; use FS::cust_attachment; -use FS::contact; +use FS::cust_contact; use FS::Locales; use FS::upgrade_journal; use FS::sales; @@ -529,11 +529,23 @@ sub insert { return $error; } - my @contact = $prospect_main->contact; + foreach my $prospect_contact ( $prospect_main->prospect_contact ) { + my $cust_contact = new FS::cust_contact { + 'custnum' => $self->custnum, + map { $_ => $prospect_contact->$_() } qw( contactnum classnum comment ) + }; + my $error = $cust_contact->insert + || $prospect_contact->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + my @cust_location = $prospect_main->cust_location; my @qual = $prospect_main->qual; - foreach my $r ( @contact, @cust_location, @qual ) { + foreach my $r ( @cust_location, @qual ) { $r->prospectnum(''); $r->custnum($self->custnum); my $error = $r->replace; @@ -1915,14 +1927,13 @@ sub cust_location { =item cust_contact -Returns all contacts (see L) for this customer. +Returns all contact associations (see L) for this customer. =cut -#already used :/ sub contact { sub cust_contact { my $self = shift; - qsearch('contact', { 'custnum' => $self->custnum } ); + qsearch('cust_contact', { 'custnum' => $self->custnum } ); } =item cust_payby @@ -3656,9 +3667,11 @@ sub service_contact { my $classnum = $self->scalar_sql( 'SELECT classnum FROM contact_class WHERE classname = \'Service\'' ) || 0; #if it's zero, qsearchs will return nothing - $self->{service_contact} = qsearchs('contact', { - 'classnum' => $classnum, 'custnum' => $self->custnum - }) || undef; + my $cust_contact = qsearchs('cust_contact', { + 'classnum' => $classnum, + 'custnum' => $self->custnum, + }); + $self->{service_contact} = $cust_contact->contact if $cust_contact; } $self->{service_contact}; } @@ -4614,6 +4627,42 @@ sub _agent_plandata { } +sub process_o2m_qsearch { + my $self = shift; + my $table = shift; + return qsearch($table, @_) unless $table eq 'contact'; + + my $hashref = shift; + my %hash = %$hashref; + ( my $custnum = delete $hash{'custnum'} ) =~ /^(\d+)$/ + or die 'guru meditation #4343'; + + qsearch({ 'table' => 'contact', + 'addl_from' => 'LEFT JOIN cust_contact USING ( contactnum )', + 'hashref' => \%hash, + 'extra_sql' => ( keys %hash ? ' AND ' : ' WHERE ' ). + " cust_contact.custnum = $custnum " + }); +} + +sub process_o2m_qsearchs { + my $self = shift; + my $table = shift; + return qsearchs($table, @_) unless $table eq 'contact'; + + my $hashref = shift; + my %hash = %$hashref; + ( my $custnum = delete $hash{'custnum'} ) =~ /^(\d+)$/ + or die 'guru meditation #2121'; + + qsearchs({ 'table' => 'contact', + 'addl_from' => 'LEFT JOIN cust_contact USING ( contactnum )', + 'hashref' => \%hash, + 'extra_sql' => ( keys %hash ? ' AND ' : ' WHERE ' ). + " cust_contact.custnum = $custnum " + }); +} + =item queued_bill 'custnum' => CUSTNUM [ , OPTION => VALUE ... ] Subroutine (not a method), designed to be called from the queue. diff --git a/FS/FS/msg_template.pm b/FS/FS/msg_template.pm index f45fb2aef..94d478f6f 100644 --- a/FS/FS/msg_template.pm +++ b/FS/FS/msg_template.pm @@ -278,16 +278,17 @@ A hash reference of additional substitutions sub prepare { my( $self, %opt ) = @_; - my $cust_main = $opt{'cust_main'} or die 'cust_main required'; + my $cust_main = $opt{'cust_main'}; # or die 'cust_main required'; my $object = $opt{'object'} or die 'object required'; # localization - my $locale = $cust_main->locale || ''; + my $locale = $cust_main && $cust_main->locale || ''; warn "no locale for cust#".$cust_main->custnum."; using default content\n" - if $DEBUG and !$locale; - my $content = $self->content($cust_main->locale); - warn "preparing template '".$self->msgname."' to cust#".$cust_main->custnum."\n" - if($DEBUG); + if $DEBUG and $cust_main && !$locale; + my $content = $self->content($locale); + + warn "preparing template '".$self->msgname."\n" + if $DEBUG; my $subs = $self->substitutions; @@ -295,7 +296,8 @@ sub prepare { # create substitution table ### my %hash; - my @objects = ($cust_main); + my @objects = (); + push @objects, $cust_main if $cust_main; my @prefixes = (''); my $svc; if( ref $object ) { @@ -385,20 +387,22 @@ sub prepare { my @to; if ( exists($opt{'to'}) ) { @to = split(/\s*,\s*/, $opt{'to'}); - } - else { + } elsif ( $cust_main ) { @to = $cust_main->invoicing_list_emailonly; + } else { + die 'no To: address or cust_main object specified'; } - # no warning when preparing with no destination my $from_addr = $self->from_addr; if ( !$from_addr ) { + + my $agentnum = $cust_main ? $cust_main->agentnum : ''; + if ( $opt{'from_config'} ) { - $from_addr = scalar( $conf->config($opt{'from_config'}, - $cust_main->agentnum) ); + $from_addr = $conf->config($opt{'from_config'}, $agentnum); } - $from_addr ||= $conf->invoice_from_full($cust_main->agentnum); + $from_addr ||= $conf->invoice_from_full($agentnum); } # my @cust_msg = (); # if ( $conf->exists('log_sent_mail') and !$opt{'preview'} ) { @@ -416,11 +420,11 @@ sub prepare { ->format( HTML::TreeBuilder->new_from_content($body) ) ); ( - 'custnum' => $cust_main->custnum, - 'msgnum' => $self->msgnum, - 'from' => $from_addr, - 'to' => \@to, - 'bcc' => $self->bcc_addr || undef, + 'custnum' => ( $cust_main ? $cust_main->custnum : ''), + 'msgnum' => $self->msgnum, + 'from' => $from_addr, + 'to' => \@to, + 'bcc' => $self->bcc_addr || undef, 'subject' => $subject, 'html_body' => $body, 'text_body' => $text_body diff --git a/FS/FS/o2m_Common.pm b/FS/FS/o2m_Common.pm index 0e03b52ee..4848649d3 100644 --- a/FS/FS/o2m_Common.pm +++ b/FS/FS/o2m_Common.pm @@ -87,7 +87,7 @@ sub process_o2m { foreach my $del_obj ( grep { ! $edits{$_->$table_pkey()} } - qsearch( $table, $hashref ) + $self->process_o2m_qsearch( $table, $hashref ) ) { my $error = $del_obj->delete; if ( $error ) { @@ -97,7 +97,7 @@ sub process_o2m { } foreach my $pkey_value ( keys %edits ) { - my $old_obj = qsearchs( $table, { %$hashref, $table_pkey => $pkey_value } ), + my $old_obj = $self->process_o2m_qsearchs( $table, { %$hashref, $table_pkey => $pkey_value } ); my $add_param = $edits{$pkey_value}; my %hash = ( $table_pkey => $pkey_value, map { $_ => $opt{'params'}->{$add_param."_$_"} } @@ -131,6 +131,9 @@ sub process_o2m { ''; } +sub process_o2m_qsearch { shift->qsearch( @_ ); } +sub process_o2m_qsearchs { shift->qsearchs( @_ ); } + sub _load_table { my( $self, $table ) = @_; eval "use FS::$table"; diff --git a/FS/FS/prospect_contact.pm b/FS/FS/prospect_contact.pm new file mode 100644 index 000000000..6626132dd --- /dev/null +++ b/FS/FS/prospect_contact.pm @@ -0,0 +1,125 @@ +package FS::prospect_contact; +use base qw( FS::Record ); + +use strict; +use FS::Record qw( qsearch qsearchs ); + +=head1 NAME + +FS::prospect_contact - Object methods for prospect_contact records + +=head1 SYNOPSIS + + use FS::prospect_contact; + + $record = new FS::prospect_contact \%hash; + $record = new FS::prospect_contact { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::prospect_contact object represents a contact's attachment to a specific +prospect. FS::prospect_contact inherits from FS::Record. The following fields +are currently supported: + +=over 4 + +=item prospectcontactnum + +primary key + +=item prospectnum + +prospectnum + +=item contactnum + +contactnum + +=item classnum + +classnum + +=item comment + +comment + + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new record. To add the record to the database, see L<"insert">. + +Note that this stores the hash reference, not a distinct copy of the hash it +points to. You can ask the object for a copy with the I method. + +=cut + +# the new method can be inherited from FS::Record, if a table method is defined + +sub table { 'prospect_contact'; } + +=item insert + +Adds this record to the database. If there is an error, returns the error, +otherwise returns false. + +=item delete + +Delete this record from the database. + +=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. + +=item check + +Checks all fields to make sure this is a valid record. If there is +an error, returns the error, otherwise returns false. Called by the insert +and replace methods. + +=cut + +# the check method should currently be supplied - FS::Record contains some +# data checking routines + +sub check { + my $self = shift; + + my $error = + $self->ut_numbern('prospectcontactnum') + || $self->ut_number('prospectnum') + || $self->ut_number('contactnum') + || $self->ut_numbern('classnum') + || $self->ut_textn('comment') + ; + return $error if $error; + + $self->SUPER::check; +} + +=back + +=head1 BUGS + +=head1 SEE ALSO + +L, L, L + +=cut + +1; + diff --git a/FS/FS/prospect_main.pm b/FS/FS/prospect_main.pm index b160343de..81f71a996 100644 --- a/FS/FS/prospect_main.pm +++ b/FS/FS/prospect_main.pm @@ -269,7 +269,7 @@ sub name { my $self = shift; return $self->company if $self->company; - my $contact = ($self->contact)[0]; #first contact? good enough for now + my $contact = ($self->prospect_contact)[0]->contact; #first contact? good enough for now return $contact->line if $contact; 'Prospect #'. $self->prospectnum; @@ -314,7 +314,7 @@ sub convert_cust_main { my @cust_location = $self->cust_location; #the interface only allows one, so we're just gonna go with that for now - my @contact = $self->contact; + my @contact = map $_->contact, $self->prospect_contact; #XXX define one contact type as "billing", then we could pick just that one my @invoicing_list = map $_->emailaddress, map $_->contact_email, @contact; diff --git a/FS/MANIFEST b/FS/MANIFEST index 6e36c3344..e5e29b4c9 100644 --- a/FS/MANIFEST +++ b/FS/MANIFEST @@ -834,3 +834,7 @@ FS/svc_circuit.pm t/svc_circuit.t FS/cust_credit_source_bill_pkg.pm t/cust_credit_source_bill_pkg.t +FS/prospect_contact.pm +t/prospect_contact.t +FS/cust_contact.pm +t/cust_contact.t diff --git a/FS/t/cust_contact.t b/FS/t/cust_contact.t new file mode 100644 index 000000000..0e9ea7100 --- /dev/null +++ b/FS/t/cust_contact.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::cust_contact; +$loaded=1; +print "ok 1\n"; diff --git a/FS/t/prospect_contact.t b/FS/t/prospect_contact.t new file mode 100644 index 000000000..dbb12e510 --- /dev/null +++ b/FS/t/prospect_contact.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::prospect_contact; +$loaded=1; +print "ok 1\n"; diff --git a/fs_selfservice/FS-SelfService/SelfService.pm b/fs_selfservice/FS-SelfService/SelfService.pm index f54a1571e..3aa60a0c0 100644 --- a/fs_selfservice/FS-SelfService/SelfService.pm +++ b/fs_selfservice/FS-SelfService/SelfService.pm @@ -30,6 +30,7 @@ $socket .= '.'.$tag if defined $tag && length($tag); 'login' => 'MyAccount/login', 'logout' => 'MyAccount/logout', 'switch_acct' => 'MyAccount/switch_acct', + 'switch_cust' => 'MyAccount/switch_cust', 'customer_info' => 'MyAccount/customer_info', 'customer_info_short' => 'MyAccount/customer_info_short', 'billing_history' => 'MyAccount/billing_history', diff --git a/fs_selfservice/FS-SelfService/cgi/select_cust.html b/fs_selfservice/FS-SelfService/cgi/select_cust.html new file mode 100644 index 000000000..7ab55db45 --- /dev/null +++ b/fs_selfservice/FS-SelfService/cgi/select_cust.html @@ -0,0 +1,38 @@ + + + Select customer + <%= $head %> + + + <%= $body_header %> + +Select customer

+<%= $error %> + +<%= $selfurl =~ s/\?.*//; ''; %> +
+ + + + + + + + + + + + + + +
Customer + +
+
+ +<%= $body_footer %> diff --git a/fs_selfservice/FS-SelfService/cgi/selfservice.cgi b/fs_selfservice/FS-SelfService/cgi/selfservice.cgi index 9443a7de7..2337fb51e 100755 --- a/fs_selfservice/FS-SelfService/cgi/selfservice.cgi +++ b/fs_selfservice/FS-SelfService/cgi/selfservice.cgi @@ -81,6 +81,7 @@ my @actions = ( qw( process_change_password customer_suspend_pkg process_suspend_pkg + switch_cust )); my @nologin_actions = (qw( @@ -204,6 +205,12 @@ unless ( $nologin_actions{$action} ) { # at this point $session_id is a real session + if ( ! $login_rv->{'custnum'} && ! $login_rv->{'svcnum'} && $login_rv->{'customers'} ) { + #select a customer if we're a multi-contact customer + do_template('select_cust', { %$login_rv } ); + exit; + } + } warn "calling $action sub\n" @@ -212,6 +219,7 @@ $FS::SelfService::DEBUG = $DEBUG; my $result = eval "&$action();"; die $@ if $@; +use Data::Dumper; warn Dumper($result) if $DEBUG; if ( $result->{error} && ( $result->{error} eq "Can't resume session" @@ -237,7 +245,13 @@ do_template($action, { #-- -use Data::Dumper; +sub switch_cust { + $action = 'myaccount'; + FS::SelfService::switch_cust( 'session_id' => $session_id, + 'custnum' => scalar($cgi->param('custnum')), + ); +} + sub myaccount { customer_info( 'session_id' => $session_id ); } diff --git a/httemplate/edit/cust_main.cgi b/httemplate/edit/cust_main.cgi index 353ae1799..da87bfca7 100755 --- a/httemplate/edit/cust_main.cgi +++ b/httemplate/edit/cust_main.cgi @@ -325,8 +325,8 @@ if ( $cgi->param('error') ) { $cust_main->company( $prospect_main->company ); #first contact? -> name - my @contacts = $prospect_main->contact; - my $contact = $contacts[0]; + my @prospect_contacts = $prospect_main->prospect_contact; + my $contact = $prospect_contacts[0]->contact; $cust_main->first( $contact->first ); $cust_main->set( 'last', $contact->get('last') ); #contact phone numbers? diff --git a/httemplate/edit/elements/edit.html b/httemplate/edit/elements/edit.html index 9e506a731..4d5beee71 100644 --- a/httemplate/edit/elements/edit.html +++ b/httemplate/edit/elements/edit.html @@ -334,6 +334,10 @@ Example: % #any? % 'colspan' => $f->{'colspan'}, % 'required' => $f->{'required'}, +% +% #contact +% 'custnum' => $f->{'custnum'}, +% 'prospectnum' => $f->{'prospectnum'}, % ); % % $include_common{$_} = $f->{$_} foreach grep exists($f->{$_}), diff --git a/httemplate/elements/contact.html b/httemplate/elements/contact.html index 979c26b49..ef74481c0 100644 --- a/httemplate/elements/contact.html +++ b/httemplate/elements/contact.html @@ -9,7 +9,7 @@