sprinkle some magic ajax fairy dust on referring customer SELEKTAH. rewind! make...
[freeside.git] / FS / FS / cust_main.pm
index a2eb724..7962f6d 100644 (file)
@@ -20,6 +20,7 @@ use Date::Parse;
 #use Date::Manip;
 use String::Approx qw(amatch);
 use Business::CreditCard 0.28;
+use Locale::Country;
 use FS::UID qw( getotaker dbh );
 use FS::Record qw( qsearchs qsearch dbdef );
 use FS::Misc qw( send_email );
@@ -79,7 +80,7 @@ sub _cache {
   my $self = shift;
   my ( $hashref, $cache ) = @_;
   if ( exists $hashref->{'pkgnum'} ) {
-#    #@{ $self->{'_pkgnum'} } = ();
+    #@{ $self->{'_pkgnum'} } = ();
     my $subcache = $cache->subcache( 'pkgnum', 'cust_pkg', $hashref->{custnum});
     $self->{'_pkgnum'} = $subcache;
     #push @{ $self->{'_pkgnum'} },
@@ -923,6 +924,8 @@ sub replace {
   my $self = shift;
   my $old = shift;
   my @param = @_;
+  warn "$me replace called\n"
+    if $DEBUG;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -941,10 +944,13 @@ sub replace {
     $old = qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
   }
 
-  if ( $self->payby eq 'COMP' && $self->payby ne $old->payby
-       && $conf->config('users-allow_comp')                  ) {
-    return "You are not permitted to create complimentary accounts."
-      unless grep { $_ eq getotaker } $conf->config('users-allow_comp');
+  my $curuser = $FS::CurrentUser::CurrentUser;
+  if (    $self->payby eq 'COMP'
+       && $self->payby ne $old->payby
+       && ! $curuser->access_right('Complimentary customer')
+     )
+  {
+    return "You are not permitted to create complimentary accounts.";
   }
 
   local($ignore_expired_card) = 1
@@ -1054,6 +1060,7 @@ sub check {
   my $error =
     $self->ut_numbern('custnum')
     || $self->ut_number('agentnum')
+    || $self->ut_textn('agent_custid')
     || $self->ut_number('refnum')
     || $self->ut_name('last')
     || $self->ut_name('first')
@@ -1213,7 +1220,12 @@ sub check {
       if cardtype($self->payinfo) eq "Unknown";
 
     my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
-    return "Banned credit card" if $ban;
+    if ( $ban ) {
+      return 'Banned credit card: banned on '.
+             time2str('%a %h %o at %r', $ban->_date).
+             ' by '. $ban->otaker.
+             ' (ban# '. $ban->bannum. ')';
+    }
 
     if ( defined $self->dbdef_table->column('paycvv') ) {
       if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
@@ -1261,15 +1273,21 @@ sub check {
     $payinfo =~ s/[^\d\@]//g;
     if ( $conf->exists('echeck-nonus') ) {
       $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@aba';
+      $payinfo = "$1\@$2";
     } else {
       $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
+      $payinfo = "$1\@$2";
     }
-    $payinfo = "$1\@$2";
     $self->payinfo($payinfo);
     $self->paycvv('') if $self->dbdef_table->column('paycvv');
 
     my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
-    return "Banned ACH account" if $ban;
+    if ( $ban ) {
+      return 'Banned ACH account: banned on '.
+             time2str('%a %h %o at %r', $ban->_date).
+             ' by '. $ban->otaker.
+             ' (ban# '. $ban->bannum. ')';
+    }
 
   } elsif ( $self->payby eq 'LECB' ) {
 
@@ -1288,9 +1306,12 @@ sub check {
 
   } elsif ( $self->payby eq 'COMP' ) {
 
-    if ( !$self->custnum && $conf->config('users-allow_comp') ) {
+    my $curuser = $FS::CurrentUser::CurrentUser;
+    if (    ! $self->custnum
+         && ! $curuser->access_right('Complimentary customer')
+       )
+    {
       return "You are not permitted to create complimentary accounts."
-        unless grep { $_ eq getotaker } $conf->config('users-allow_comp');
     }
 
     $error = $self->ut_textn('payinfo');
@@ -1802,29 +1823,26 @@ sub bill {
 
         unless ( $self->tax =~ /Y/i || $self->payby eq 'COMP' ) {
 
-          my @taxes = qsearch( 'cust_main_county', {
-                                 'state'    => $self->state,
-                                 'county'   => $self->county,
-                                 'country'  => $self->country,
-                                 'taxclass' => $part_pkg->taxclass,
-                                                                      } );
+          my $prefix = 
+            ( $conf->exists('tax-ship_address') && length($self->ship_last) )
+            ? 'ship_'
+            : '';
+          my %taxhash = map { $_ => $self->get("$prefix$_") }
+                            qw( state county country );
+
+          $taxhash{'taxclass'} = $part_pkg->taxclass;
+
+          my @taxes = qsearch( 'cust_main_county', \%taxhash );
+
           unless ( @taxes ) {
-            @taxes =  qsearch( 'cust_main_county', {
-                                  'state'    => $self->state,
-                                  'county'   => $self->county,
-                                  'country'  => $self->country,
-                                  'taxclass' => '',
-                                                                      } );
+            $taxhash{'taxclass'} = '';
+            @taxes =  qsearch( 'cust_main_county', \%taxhash );
           }
 
           #one more try at a whole-country tax rate
           unless ( @taxes ) {
-            @taxes =  qsearch( 'cust_main_county', {
-                                  'state'    => '',
-                                  'county'   => '',
-                                  'country'  => $self->country,
-                                  'taxclass' => '',
-                                                                      } );
+            $taxhash{$_} = '' foreach qw( state county );
+            @taxes =  qsearch( 'cust_main_county', \%taxhash );
           }
 
           # maybe eliminate this entirely, along with all the 0% records
@@ -1832,8 +1850,10 @@ sub bill {
             $dbh->rollback if $oldAutoCommit;
             return
               "fatal: can't find tax rate for state/county/country/taxclass ".
-              join('/', ( map $self->$_(), qw(state county country) ),
-                        $part_pkg->taxclass ).  "\n";
+              join('/', ( map $self->get("$prefix$_"),
+                              qw(state county country)
+                        ),
+                        $part_pkg->taxclass ). "\n";
           }
   
           foreach my $tax ( @taxes ) {
@@ -2027,6 +2047,8 @@ quiet - set true to surpress email card/ACH decline notices.
 freq - "1d" for the traditional, daily events (the default), or "1m" for the
 new monthly events
 
+payby - allows for one time override of normal customer billing method
+
 =cut
 
 sub collect {
@@ -2097,7 +2119,10 @@ sub collect {
              }
           qsearch( {
             'table'     => 'part_bill_event',
-            'hashref'   => { 'payby'    => $self->payby,
+            'hashref'   => { 'payby'    => (exists($options{'payby'})
+                                            ? $options{'payby'}
+                                            : $self->payby
+                                          ),
                              'disabled' => '',           },
             'extra_sql' => $extra_sql,
           } )
@@ -3124,6 +3149,29 @@ sub balance_date {
   );
 }
 
+=item in_transit_payments
+
+Returns the total of requests for payments for this customer pending in 
+batches in transit to the bank.  See L<FS::pay_batch> and L<FS::cust_pay_batch>
+
+=cut
+
+sub in_transit_payments {
+  my $self = shift;
+  my $in_transit_payments = 0;
+  foreach my $pay_batch ( qsearch('pay_batch', {
+    'status' => 'I',
+  } ) ) {
+    foreach my $cust_pay_batch ( qsearch('cust_pay_batch', {
+      'batchnum' => $pay_batch->batchnum,
+      'custnum' => $self->custnum,
+    } ) ) {
+      $in_transit_payments += $cust_pay_batch->amount;
+    }
+  }
+  sprintf( "%.2f", $in_transit_payments );
+}
+
 =item paydate_monthyear
 
 Returns a two-element list consisting of the month and year of this customer's
@@ -3174,6 +3222,7 @@ This interface may change in the future.
 
 sub invoicing_list {
   my( $self, $arrayref ) = @_;
+
   if ( $arrayref ) {
     my @cust_main_invoice;
     if ( $self->custnum ) {
@@ -3208,12 +3257,14 @@ sub invoicing_list {
       warn $error if $error;
     }
   }
+  
   if ( $self->custnum ) {
     map { $_->address }
       qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
   } else {
     ();
   }
+
 }
 
 =item check_invoicing_list ARRAYREF
@@ -3291,6 +3342,18 @@ sub invoicing_list_addpost {
   $self->invoicing_list(\@invoicing_list);
 }
 
+=item invoicing_list_emailonly
+
+Returns the list of email invoice recipients (invoicing_list without non-email
+destinations such as POST and FAX).
+
+=cut
+
+sub invoicing_list_emailonly {
+  my $self = shift;
+  grep { $_ !~ /^([A-Z]+)$/ } $self->invoicing_list;
+}
+
 =item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
 
 Returns an array of customers referred by this customer (referral_custnum set
@@ -3588,6 +3651,17 @@ sub ship_contact {
     : $self->contact;
 }
 
+=item country_full
+
+Returns this customer's full country name
+
+=cut
+
+sub country_full {
+  my $self = shift;
+  code2country($self->country);
+}
+
 =item status
 
 Returns a status string for this customer, currently:
@@ -3598,6 +3672,8 @@ Returns a status string for this customer, currently:
 
 =item active - One or more recurring packages is active
 
+=item inactive - No active recurring packages, but otherwise unsuspended/uncancelled (the inactive status is new - previously inactive customers were mis-identified as cancelled)
+
 =item suspended - All non-cancelled recurring packages are suspended
 
 =item cancelled - All recurring packages are cancelled
@@ -3608,7 +3684,7 @@ Returns a status string for this customer, currently:
 
 sub status {
   my $self = shift;
-  for my $status (qw( prospect active suspended cancelled )) {
+  for my $status (qw( prospect active inactive suspended cancelled )) {
     my $method = $status.'_sql';
     my $numnum = ( my $sql = $self->$method() ) =~ s/cust_main\.custnum/?/g;
     my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr;
@@ -3623,12 +3699,15 @@ Returns a hex triplet color string for this customer's status.
 
 =cut
 
-my %statuscolor = (
-  'prospect'  => '000000',
-  'active'    => '00CC00',
-  'suspended' => 'FF9900',
-  'cancelled' => 'FF0000',
+use vars qw(%statuscolor);
+%statuscolor = (
+  'prospect'  => '7e0079', #'000000', #black?  naw, purple
+  'active'    => '00CC00', #green
+  'inactive'  => '0000CC', #blue
+  'suspended' => 'FF9900', #yellow
+  'cancelled' => 'FF0000', #red
 );
+
 sub statuscolor {
   my $self = shift;
   $statuscolor{$self->status};
@@ -3647,25 +3726,44 @@ with no packages ever ordered)
 
 =cut
 
+use vars qw($select_count_pkgs);
+$select_count_pkgs =
+  "SELECT COUNT(*) FROM cust_pkg
+    WHERE cust_pkg.custnum = cust_main.custnum";
+
+sub select_count_pkgs_sql {
+  $select_count_pkgs;
+}
+
 sub prospect_sql { "
-  0 = ( SELECT COUNT(*) FROM cust_pkg
-          WHERE cust_pkg.custnum = cust_main.custnum
-      )
+  0 = ( $select_count_pkgs )
 "; }
 
 =item active_sql
 
-Returns an SQL expression identifying active cust_main records.
+Returns an SQL expression identifying active cust_main records (customers with
+no active recurring packages, but otherwise unsuspended/uncancelled).
 
 =cut
 
 sub active_sql { "
-  0 < ( SELECT COUNT(*) FROM cust_pkg
-          WHERE cust_pkg.custnum = cust_main.custnum
-            AND ". FS::cust_pkg->active_sql. "
+  0 < ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. "
       )
 "; }
 
+=item inactive_sql
+
+Returns an SQL expression identifying inactive cust_main records (customers with
+active recurring packages).
+
+=cut
+
+sub inactive_sql { "
+  0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " )
+  AND
+  0 < ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " )
+"; }
+
 =item susp_sql
 =item suspended_sql
 
@@ -3673,23 +3771,12 @@ Returns an SQL expression identifying suspended cust_main records.
 
 =cut
 
-#my $recurring_sql = FS::cust_pkg->recurring_sql;
-my $recurring_sql = "
-  '0' != ( select freq from part_pkg
-             where cust_pkg.pkgpart = part_pkg.pkgpart )
-";
 
 sub suspended_sql { susp_sql(@_); }
 sub susp_sql { "
-    0 < ( SELECT COUNT(*) FROM cust_pkg
-            WHERE cust_pkg.custnum = cust_main.custnum
-              AND $recurring_sql
-              AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
-        )
-    AND 0 = ( SELECT COUNT(*) FROM cust_pkg
-                WHERE cust_pkg.custnum = cust_main.custnum
-                  AND ". FS::cust_pkg->active_sql. "
-            )
+    0 < ( $select_count_pkgs AND ". FS::cust_pkg->suspended_sql. " )
+    AND
+    0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " )
 "; }
 
 =item cancel_sql
@@ -3700,22 +3787,45 @@ Returns an SQL expression identifying cancelled cust_main records.
 =cut
 
 sub cancelled_sql { cancel_sql(@_); }
-sub cancel_sql { "
-  0 < ( SELECT COUNT(*) FROM cust_pkg
-          WHERE cust_pkg.custnum = cust_main.custnum
-      )
-  AND 0 = ( SELECT COUNT(*) FROM cust_pkg
-              WHERE cust_pkg.custnum = cust_main.custnum
-                AND $recurring_sql
-                AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
-          )
+sub cancel_sql {
+
+  my $recurring_sql = FS::cust_pkg->recurring_sql;
+  #my $recurring_sql = "
+  #  '0' != ( select freq from part_pkg
+  #             where cust_pkg.pkgpart = part_pkg.pkgpart )
+  #";
+
+  "
+    0 < ( $select_count_pkgs )
+    AND 0 = ( $select_count_pkgs AND $recurring_sql
+                  AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
+            )
+  ";
+}
+
+=item uncancel_sql
+=item uncancelled_sql
+
+Returns an SQL expression identifying un-cancelled cust_main records.
+
+=cut
+
+sub uncancelled_sql { uncancel_sql(@_); }
+sub uncancel_sql { "
+  ( 0 < ( $select_count_pkgs
+                   AND ( cust_pkg.cancel IS NULL
+                         OR cust_pkg.cancel = 0
+                       )
+        )
+    OR 0 = ( $select_count_pkgs )
+  )
 "; }
 
 =item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ]
 
 Performs a fuzzy (approximate) search and returns the matching FS::cust_main
-records.  Currently, only I<last> or I<company> may be specified (the
-appropriate ship_ field is also searched if applicable).
+records.  Currently, I<first>, I<last> and/or I<company> may be specified (the
+appropriate ship_ field is also searched).
 
 Additional options are the same as FS::Record::qsearch
 
@@ -3729,19 +3839,25 @@ sub fuzzy_search {
 
   check_and_rebuild_fuzzyfiles();
   foreach my $field ( keys %$fuzzy ) {
-    my $sub = \&{"all_$field"};
     my %match = ();
-    $match{$_}=1 foreach ( amatch($fuzzy->{$field}, ['i'], @{ &$sub() } ) );
+    $match{$_}=1 foreach ( amatch( $fuzzy->{$field},
+                                   ['i'],
+                                   @{ $self->all_X($field) }
+                                 )
+                         );
 
+    my @fcust = ();
     foreach ( keys %match ) {
-      push @cust_main, qsearch('cust_main', { %$hash, $field=>$_}, @opt);
-      push @cust_main, qsearch('cust_main', { %$hash, "ship_$field"=>$_}, @opt)
-        if defined dbdef->table('cust_main')->column('ship_last');
+      push @fcust, qsearch('cust_main', { %$hash, $field=>$_}, @opt);
+      push @fcust, qsearch('cust_main', { %$hash, "ship_$field"=>$_}, @opt);
     }
+    my %fsaw = ();
+    push @cust_main, grep { ! $fsaw{$_->custnum}++ } @fcust;
   }
 
+  # we want the components of $fuzzy ANDed, not ORed, but still don't want dupes
   my %saw = ();
-  @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
+  @cust_main = grep { ++$saw{$_->custnum} == scalar(keys %$fuzzy) } @cust_main;
 
   @cust_main;
 
@@ -3756,8 +3872,9 @@ sub fuzzy_search {
 =item smart_search OPTION => VALUE ...
 
 Accepts the following options: I<search>, the string to search for.  The string
-will be searched for as a customer number, last name or company name, first
-searching for an exact match then fuzzy and substring matches.
+will be searched for as a customer number, phone number, name or company name,
+first searching for an exact match then fuzzy and substring matches (in some
+cases - see the source code for the exact heuristics used).
 
 Any additional options treated as an additional qualifier on the search
 (i.e. I<agentnum>).
@@ -3768,73 +3885,214 @@ Returns a (possibly empty) array of FS::cust_main objects.
 
 sub smart_search {
   my %options = @_;
-  my $search = delete $options{'search'};
+
+  #here is the agent virtualization
+  my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql;
+
   my @cust_main = ();
 
-  if ( $search =~ /^\s*(\d+)\s*$/ ) { # customer # search
+  my $search = delete $options{'search'};
+  ( my $alphanum_search = $search ) =~ s/\W//g;
+  
+  if ( $alphanum_search =~ /^1?(\d{3})(\d{3})(\d{4})(\d*)$/ ) { #phone# search
+
+    #false laziness w/Record::ut_phone
+    my $phonen = "$1-$2-$3";
+    $phonen .= " x$4" if $4;
+
+    push @cust_main, qsearch( {
+      'table'   => 'cust_main',
+      'hashref' => { %options },
+      'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
+                     ' ( '.
+                         join(' OR ', map "$_ = '$phonen'",
+                                          qw( daytime night fax
+                                              ship_daytime ship_night ship_fax )
+                             ).
+                     ' ) '.
+                     " AND $agentnums_sql", #agent virtualization
+    } );
 
-    push @cust_main, qsearch('cust_main', { 'custnum' => $1, %options } );
+    unless ( @cust_main || $phonen =~ /x\d+$/ ) { #no exact match
+      #try looking for matches with extensions unless one was specified
+
+      push @cust_main, qsearch( {
+        'table'   => 'cust_main',
+        'hashref' => { %options },
+        'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
+                       ' ( '.
+                           join(' OR ', map "$_ LIKE '$phonen\%'",
+                                            qw( daytime night
+                                                ship_daytime ship_night )
+                               ).
+                       ' ) '.
+                       " AND $agentnums_sql", #agent virtualization
+      } );
 
-  } elsif ( $search =~ /^\s*(\S.*\S)\s*$/ ) { #value search
+    }
+
+  } elsif ( $search =~ /^\s*(\d+)\s*$/ ) { # customer # search
+
+    push @cust_main, qsearch( {
+      'table'     => 'cust_main',
+      'hashref'   => { 'custnum' => $1, %options },
+      'extra_sql' => " AND $agentnums_sql", #agent virtualization
+    } );
+
+  } elsif ( $search =~ /^\s*(\S.*\S)\s+\((.+), ([^,]+)\)\s*$/ ) {
+
+    my($company, $last, $first) = ( $1, $2, $3 );
+
+    # "Company (Last, First)"
+    #this is probably something a browser remembered,
+    #so just do an exact search
+
+    foreach my $prefix ( '', 'ship_' ) {
+      push @cust_main, qsearch( {
+        'table'     => 'cust_main',
+        'hashref'   => { $prefix.'first'   => $first,
+                         $prefix.'last'    => $last,
+                         $prefix.'company' => $company,
+                         %options,
+                       },
+        'extra_sql' => " AND $agentnums_sql",
+      } );
+    }
+
+  } elsif ( $search =~ /^\s*(\S.*\S)\s*$/ ) { # value search
+                                              # try (ship_){last,company}
 
     my $value = lc($1);
+
+    # # remove "(Last, First)" in "Company (Last, First)", otherwise the
+    # # full strings the browser remembers won't work
+    # $value =~ s/\([\w \,\.\-\']*\)$//; #false laziness w/Record::ut_name
+
+    use Lingua::EN::NameParse;
+    my $NameParse = new Lingua::EN::NameParse(
+             auto_clean     => 1,
+             allow_reversed => 1,
+    );
+
+    my($last, $first) = ( '', '' );
+    #maybe disable this too and just rely on NameParse?
+    if ( $value =~ /^(.+),\s*([^,]+)$/ ) { # Last, First
+    
+      ($last, $first) = ( $1, $2 );
+    
+    #} elsif  ( $value =~ /^(.+)\s+(.+)$/ ) {
+    } elsif ( ! $NameParse->parse($value) ) {
+
+      my %name = $NameParse->components;
+      $first = $name{'given_name_1'};
+      $last  = $name{'surname_1'};
+
+    }
+
+    if ( $first && $last ) {
+
+      my($q_last, $q_first) = ( dbh->quote($last), dbh->quote($first) );
+
+      #exact
+      my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
+      $sql .= "
+        (     ( LOWER(last) = $q_last AND LOWER(first) = $q_first )
+           OR ( LOWER(ship_last) = $q_last AND LOWER(ship_first) = $q_first )
+        )";
+
+      push @cust_main, qsearch( {
+        'table'     => 'cust_main',
+        'hashref'   => \%options,
+        'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
+      } );
+
+      # or it just be something that was typed in... (try that in a sec)
+
+    }
+
     my $q_value = dbh->quote($value);
 
     #exact
     my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
-    $sql .= " ( LOWER(last) = $q_value OR LOWER(company) = $q_value";
-    $sql .= " OR LOWER(ship_last) = $q_value OR LOWER(ship_company) = $q_value"
-      if defined dbdef->table('cust_main')->column('ship_last');
-    $sql .= ' )';
-
-    push @cust_main, qsearch( 'cust_main', \%options, '', $sql );
+    $sql .= " (    LOWER(last)         = $q_value
+                OR LOWER(company)      = $q_value
+                OR LOWER(ship_last)    = $q_value
+                OR LOWER(ship_company) = $q_value
+              )";
+
+    push @cust_main, qsearch( {
+      'table'     => 'cust_main',
+      'hashref'   => \%options,
+      'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
+    } );
 
     unless ( @cust_main ) {  #no exact match, trying substring/fuzzy
 
       #still some false laziness w/ search/cust_main.cgi
 
       #substring
-      push @cust_main, qsearch( 'cust_main',
-                                { 'last'     => { 'op'    => 'ILIKE',
-                                                  'value' => "%$q_value%" },
-                                  %options,
-                                }
-                              );
-      push @cust_main, qsearch( 'cust_main',
-                                { 'ship_last' => { 'op'    => 'ILIKE',
-                                                   'value' => "%$q_value%" },
-                                  %options,
-
-                                }
-                              )
-        if defined dbdef->table('cust_main')->column('ship_last');
-
-      push @cust_main, qsearch( 'cust_main',
-                                { 'company'  => { 'op'    => 'ILIKE',
-                                                  'value' => "%$q_value%" },
-                                  %options,
-                                }
-                              );
-      push @cust_main, qsearch( 'cust_main',
-                                { 'ship_company' => { 'op' => 'ILIKE',
-                                                   'value' => "%$q_value%" },
-                                  %options,
-                                }
-                              )
-        if defined dbdef->table('cust_main')->column('ship_last');
 
-      #fuzzy
-      push @cust_main, FS::cust_main->fuzzy_search(
-        { 'last'     => $value },
-        \%options,
+      my @hashrefs = (
+        { 'company'      => { op=>'ILIKE', value=>"%$value%" }, },
+        { 'ship_company' => { op=>'ILIKE', value=>"%$value%" }, },
       );
-      push @cust_main, FS::cust_main->fuzzy_search(
-        { 'company'  => $value },
-        \%options,
+
+      if ( $first && $last ) {
+
+        push @hashrefs,
+          { 'first'        => { op=>'ILIKE', value=>"%$first%" },
+            'last'         => { op=>'ILIKE', value=>"%$last%" },
+          },
+          { 'ship_first'   => { op=>'ILIKE', value=>"%$first%" },
+            'ship_last'    => { op=>'ILIKE', value=>"%$last%" },
+          },
+        ;
+
+      } else {
+
+        push @hashrefs,
+          { 'last'         => { op=>'ILIKE', value=>"%$value%" }, },
+          { 'ship_last'    => { op=>'ILIKE', value=>"%$value%" }, },
+        ;
+      }
+
+      foreach my $hashref ( @hashrefs ) {
+
+        push @cust_main, qsearch( {
+          'table'     => 'cust_main',
+          'hashref'   => { %$hashref,
+                           %options,
+                         },
+          'extra_sql' => " AND $agentnums_sql", #agent virtualizaiton
+        } );
+
+      }
+
+      #fuzzy
+      my @fuzopts = (
+        \%options,                #hashref
+        '',                       #select
+        " AND $agentnums_sql",    #extra_sql  #agent virtualization
       );
 
+      if ( $first && $last ) {
+        push @cust_main, FS::cust_main->fuzzy_search(
+          { 'last'   => $last,    #fuzzy hashref
+            'first'  => $first }, #
+          @fuzopts
+        );
+      }
+      foreach my $field ( 'last', 'company' ) {
+        push @cust_main,
+          FS::cust_main->fuzzy_search( { $field => $value }, @fuzopts );
+      }
+
     }
 
+    #eliminate duplicates
+    my %saw = ();
+    @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
+
   }
 
   @cust_main;
@@ -3845,10 +4103,12 @@ sub smart_search {
 
 =cut
 
+use vars qw(@fuzzyfields);
+@fuzzyfields = ( 'last', 'first', 'company' );
+
 sub check_and_rebuild_fuzzyfiles {
   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
-  -e "$dir/cust_main.last" && -e "$dir/cust_main.company"
-    or &rebuild_fuzzyfiles;
+  rebuild_fuzzyfiles() if grep { ! -e "$dir/cust_main.$_" } @fuzzyfields
 }
 
 =item rebuild_fuzzyfiles
@@ -3860,72 +4120,41 @@ sub rebuild_fuzzyfiles {
   use Fcntl qw(:flock);
 
   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
+  mkdir $dir, 0700 unless -d $dir;
 
-  #last
-
-  open(LASTLOCK,">>$dir/cust_main.last")
-    or die "can't open $dir/cust_main.last: $!";
-  flock(LASTLOCK,LOCK_EX)
-    or die "can't lock $dir/cust_main.last: $!";
-
-  my @all_last = map $_->getfield('last'), qsearch('cust_main', {});
-  push @all_last,
-                 grep $_, map $_->getfield('ship_last'), qsearch('cust_main',{})
-    if defined dbdef->table('cust_main')->column('ship_last');
+  foreach my $fuzzy ( @fuzzyfields ) {
 
-  open (LASTCACHE,">$dir/cust_main.last.tmp")
-    or die "can't open $dir/cust_main.last.tmp: $!";
-  print LASTCACHE join("\n", @all_last), "\n";
-  close LASTCACHE or die "can't close $dir/cust_main.last.tmp: $!";
-
-  rename "$dir/cust_main.last.tmp", "$dir/cust_main.last";
-  close LASTLOCK;
-
-  #company
-
-  open(COMPANYLOCK,">>$dir/cust_main.company")
-    or die "can't open $dir/cust_main.company: $!";
-  flock(COMPANYLOCK,LOCK_EX)
-    or die "can't lock $dir/cust_main.company: $!";
-
-  my @all_company = grep $_ ne '', map $_->company, qsearch('cust_main',{});
-  push @all_company,
-       grep $_ ne '', map $_->ship_company, qsearch('cust_main', {})
-    if defined dbdef->table('cust_main')->column('ship_last');
-
-  open (COMPANYCACHE,">$dir/cust_main.company.tmp")
-    or die "can't open $dir/cust_main.company.tmp: $!";
-  print COMPANYCACHE join("\n", @all_company), "\n";
-  close COMPANYCACHE or die "can't close $dir/cust_main.company.tmp: $!";
-
-  rename "$dir/cust_main.company.tmp", "$dir/cust_main.company";
-  close COMPANYLOCK;
-
-}
-
-=item all_last
-
-=cut
+    open(LOCK,">>$dir/cust_main.$fuzzy")
+      or die "can't open $dir/cust_main.$fuzzy: $!";
+    flock(LOCK,LOCK_EX)
+      or die "can't lock $dir/cust_main.$fuzzy: $!";
+  
+    my @all = map $_->getfield($fuzzy), qsearch('cust_main', {});
+    push @all,
+      grep $_, map $_->getfield("ship_$fuzzy"), qsearch('cust_main',{});
+  
+    open (CACHE,">$dir/cust_main.$fuzzy.tmp")
+      or die "can't open $dir/cust_main.$fuzzy.tmp: $!";
+    print CACHE join("\n", @all), "\n";
+    close CACHE or die "can't close $dir/cust_main.$fuzzy.tmp: $!";
+  
+    rename "$dir/cust_main.$fuzzy.tmp", "$dir/cust_main.$fuzzy";
+    close LOCK;
+  }
 
-sub all_last {
-  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
-  open(LASTCACHE,"<$dir/cust_main.last")
-    or die "can't open $dir/cust_main.last: $!";
-  my @array = map { chomp; $_; } <LASTCACHE>;
-  close LASTCACHE;
-  \@array;
 }
 
-=item all_company
+=item all_X
 
 =cut
 
-sub all_company {
+sub all_X {
+  my( $self, $field ) = @_;
   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
-  open(COMPANYCACHE,"<$dir/cust_main.company")
-    or die "can't open $dir/cust_main.last: $!";
-  my @array = map { chomp; $_; } <COMPANYCACHE>;
-  close COMPANYCACHE;
+  open(CACHE,"<$dir/cust_main.$field")
+    or die "can't open $dir/cust_main.$field: $!";
+  my @array = map { chomp; $_; } <CACHE>;
+  close CACHE;
   \@array;
 }