search for 15 digit credit cards too, RT#24428
[freeside.git] / FS / FS / cust_main / Search.pm
index b9a30a5..29fd0e0 100644 (file)
@@ -9,6 +9,7 @@ use FS::Record qw( qsearch );
 use FS::cust_main;
 use FS::cust_main_invoice;
 use FS::svc_acct;
 use FS::cust_main;
 use FS::cust_main_invoice;
 use FS::svc_acct;
+use FS::payinfo_Mixin;
 
 @EXPORT_OK = qw( smart_search );
 
 
 @EXPORT_OK = qw( smart_search );
 
@@ -18,7 +19,7 @@ use FS::svc_acct;
 $DEBUG = 0;
 $me = '[FS::cust_main::Search]';
 
 $DEBUG = 0;
 $me = '[FS::cust_main::Search]';
 
-@fuzzyfields = @FS::cust_main::fuzzyfields;
+@fuzzyfields = ( 'first', 'last', 'company', 'address1' );
 
 install_callback FS::UID sub { 
   $conf = new FS::Conf;
 
 install_callback FS::UID sub { 
   $conf = new FS::Conf;
@@ -49,8 +50,12 @@ FS::cust_main::Search - Customer searching
 
 Accepts the following options: I<search>, the string to search for.  The string
 will be searched for as a customer number, phone number, name or company name,
 
 Accepts the following options: I<search>, the string to search for.  The string
 will be searched for as a customer number, phone number, name or company name,
-as an exact, or, in some cases, a substring or fuzzy match (see the source code
-for the exact heuristics used); I<no_fuzzy_on_exact>, causes smart_search to
+address (if address1-search is on), invoicing email address, or credit card
+number.
+
+Searches match as an exact, or, in some cases, a substring or fuzzy match (see
+the source code for the exact heuristics used); I<no_fuzzy_on_exact>, causes
+smart_search to
 skip fuzzy matching when an exact match is found.
 
 Any additional options are treated as an additional qualifier on the search
 skip fuzzy matching when an exact match is found.
 
 Any additional options are treated as an additional qualifier on the search
@@ -64,7 +69,8 @@ sub smart_search {
   my %options = @_;
 
   #here is the agent virtualization
   my %options = @_;
 
   #here is the agent virtualization
-  my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql;
+  my $agentnums_sql = 
+    $FS::CurrentUser::CurrentUser->agentnums_sql(table => 'cust_main');
 
   my @cust_main = ();
 
 
   my @cust_main = ();
 
@@ -84,8 +90,8 @@ sub smart_search {
       'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
                      ' ( '.
                          join(' OR ', map "$_ = '$phonen'",
       'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
                      ' ( '.
                          join(' OR ', map "$_ = '$phonen'",
-                                          qw( daytime night fax
-                                              ship_daytime ship_night ship_fax )
+                                          qw( daytime night mobile fax
+                                              ship_daytime ship_night ship_mobile ship_fax )
                              ).
                      ' ) '.
                      " AND $agentnums_sql", #agent virtualization
                              ).
                      ' ) '.
                      " AND $agentnums_sql", #agent virtualization
@@ -109,14 +115,30 @@ sub smart_search {
 
     }
 
 
     }
 
-  # custnum search (also try agent_custid), with some tweaking options if your
-  # legacy cust "numbers" have letters
   } 
   } 
+  
+  
+  if ( $search =~ /@/ ) { #invoicing email address
+      push @cust_main,
+         map $_->cust_main,
+             qsearch( {
+                        'table'     => 'cust_main_invoice',
+                        'hashref'   => { 'dest' => $search },
+                      }
+                    );
 
 
-  if ( $search =~ /^\s*(\d+)\s*$/
+  # custnum search (also try agent_custid), with some tweaking options if your
+  # legacy cust "numbers" have letters
+  } elsif ( $search =~ /^\s*(\d+)\s*$/
          || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+'
               && $search =~ /^\s*(\w\w?\d+)\s*$/
             )
          || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+'
               && $search =~ /^\s*(\w\w?\d+)\s*$/
             )
+         || ( $conf->config('cust_main-custnum-display_special')
+           # it's not currently possible for special prefixes to contain
+           # digits, so just strip off any alphabetic prefix and match 
+           # the rest to custnum
+              && $search =~ /^\s*[[:alpha:]]*(\d+)\s*$/
+            )
          || ( $conf->exists('address1-search' )
               && $search =~ /^\s*(\d+\-?\w*)\s*$/ #i.e. 1234A or 9432-D
             )
          || ( $conf->exists('address1-search' )
               && $search =~ /^\s*(\d+\-?\w*)\s*$/ #i.e. 1234A or 9432-D
             )
@@ -126,17 +148,36 @@ sub smart_search {
     my $num = $1;
 
     if ( $num =~ /^(\d+)$/ && $num <= 2147483647 ) { #need a bigint custnum? wow
     my $num = $1;
 
     if ( $num =~ /^(\d+)$/ && $num <= 2147483647 ) { #need a bigint custnum? wow
+      my $agent_custid_null = $conf->exists('cust_main-default_agent_custid')
+                                ? ' AND agent_custid IS NULL ' : '';
       push @cust_main, qsearch( {
         'table'     => 'cust_main',
         'hashref'   => { 'custnum' => $num, %options },
       push @cust_main, qsearch( {
         'table'     => 'cust_main',
         'hashref'   => { 'custnum' => $num, %options },
-        'extra_sql' => " AND $agentnums_sql", #agent virtualization
+        'extra_sql' => " AND $agentnums_sql $agent_custid_null",
       } );
     }
 
       } );
     }
 
+    # for all agents this user can see, if any of them have custnum prefixes 
+    # that match the search string, include customers that match the rest 
+    # of the custnum and belong to that agent
+    foreach my $agentnum ( $FS::CurrentUser::CurrentUser->agentnums ) {
+      my $p = $conf->config('cust_main-custnum-display_prefix', $agentnum);
+      next if !$p;
+      if ( $p eq substr($num, 0, length($p)) ) {
+        push @cust_main, qsearch( {
+          'table'   => 'cust_main',
+          'hashref' => { 'custnum' => 0 + substr($num, length($p)),
+                         'agentnum' => $agentnum,
+                          %options,
+                       },
+        } );
+      }
+    }
+
     push @cust_main, qsearch( {
     push @cust_main, qsearch( {
-      'table'     => 'cust_main',
-      'hashref'   => { 'agent_custid' => $num, %options },
-      'extra_sql' => " AND $agentnums_sql", #agent virtualization
+        'table'     => 'cust_main',
+        'hashref'   => { 'agent_custid' => $num, %options },
+        'extra_sql' => " AND $agentnums_sql", #agent virtualization
     } );
 
     if ( $conf->exists('address1-search') ) {
     } );
 
     if ( $conf->exists('address1-search') ) {
@@ -329,6 +370,28 @@ sub smart_search {
 
   }
 
 
   }
 
+  ( my $nospace_search = $search ) =~ s/\s//g;
+  ( my $card_search = $nospace_search ) =~ s/\-//g;
+  $card_search =~ s/[x\*\.\_]/x/gi;
+  
+  if ( $nospace_search =~ /^[\dx]{15,16}$/i ) { #credit card search
+
+    ( my $like_search = $card_search ) =~ s/x/_/g;
+    my $mask_search = FS::payinfo_Mixin->mask_payinfo('CARD', $card_search);
+
+    push @cust_main, qsearch({
+      'table'     => 'cust_main',
+      'hashref'   => {},
+      'extra_sql' => " WHERE (    payinfo LIKE '$like_search'
+                               OR paymask =    '$mask_search'
+                             ) ".
+                     " AND payby IN ('CARD','DCRD') ".
+                     " AND $agentnums_sql", #agent virtulization
+    });
+
+  }
+  
+
   #eliminate duplicates
   my %saw = ();
   @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
   #eliminate duplicates
   my %saw = ();
   @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
@@ -424,6 +487,8 @@ HASHREF.  Valid parameters are
 
 =item address
 
 
 =item address
 
+=item refnum
+
 =item cancelled_pkgs
 
 bool
 =item cancelled_pkgs
 
 bool
@@ -432,6 +497,18 @@ bool
 
 listref of start date, end date
 
 
 listref of start date, end date
 
+=item birthdate
+
+listref of start date, end date
+
+=item spouse_birthdate
+
+listref of start date, end date
+
+=item anniversary_date
+
+listref of start date, end date
+
 =item payby
 
 listref
 =item payby
 
 listref
@@ -462,6 +539,33 @@ sub search {
   my @where = ();
   my $orderby;
 
   my @where = ();
   my $orderby;
 
+  # initialize these to prevent warnings
+  $params = {
+    'custnum'       => '',
+    'agentnum'      => '',
+    'usernum'       => '',
+    'status'        => '',
+    'address'       => '',
+    'paydate_year'  => '',
+    'invoice_terms' => '',
+    'custbatch'     => '',
+    %$params
+  };
+
+  ##
+  # explicit custnum(s)
+  ##
+
+  if ( $params->{'custnum'} ) {
+    my @custnums = ref($params->{'custnum'}) ? 
+                      @{ $params->{'custnum'} } : 
+                      $params->{'custnum'};
+    push @where, 
+      'cust_main.custnum IN (' . 
+      join(',', map { $_ =~ /^(\d+)$/ ? $1 : () } @custnums ) .
+      ')' if scalar(@custnums) > 0;
+  }
+
   ##
   # parse agent
   ##
   ##
   # parse agent
   ##
@@ -503,6 +607,22 @@ sub search {
                  ')';
   }
 
                  ')';
   }
 
+  ###
+  # refnum
+  ###
+  if ( $params->{'refnum'}  ) {
+
+    my @refnum = ref( $params->{'refnum'} )
+                   ? @{ $params->{'refnum'} }
+                   :  ( $params->{'refnum'} );
+
+    @refnum = grep /^(\d*)$/, @refnum;
+
+    push @where, '( '. join(' OR ', map "cust_main.refnum = $_", @refnum ). ' )'
+      if @refnum;
+
+  }
+
   ##
   # parse cancelled package checkbox
   ##
   ##
   # parse cancelled package checkbox
   ##
@@ -527,10 +647,31 @@ sub search {
     if $params->{'with_geocode'};
 
   ##
     if $params->{'with_geocode'};
 
   ##
+  # "with email address(es)" checkbox
+  ##
+
+  push @where,
+    'EXISTS ( SELECT 1 FROM cust_main_invoice
+                WHERE cust_main_invoice.custnum = cust_main.custnum
+                  AND length(dest) > 5
+            )'  # AND dest LIKE '%@%'
+    if $params->{'with_email'};
+
+  ##
+  # "without postal mail invoices" checkbox
+  ##
+
+  push @where,
+    "NOT EXISTS ( SELECT 1 FROM cust_main_invoice
+                    WHERE cust_main_invoice.custnum = cust_main.custnum
+                      AND dest = 'POST' )"
+    if $params->{'no_POST'};
+
+  ##
   # dates
   ##
 
   # dates
   ##
 
-  foreach my $field (qw( signupdate )) {
+  foreach my $field (qw( signupdate birthdate spouse_birthdate anniversary_date )) {
 
     next unless exists($params->{$field});
 
 
     next unless exists($params->{$field});
 
@@ -541,11 +682,13 @@ sub search {
       "cust_main.$field >= $beginning",
       "cust_main.$field <= $ending";
 
       "cust_main.$field >= $beginning",
       "cust_main.$field <= $ending";
 
-    # XXX: do this for mysql and/or pull it out of here
-    if(defined $hour) {
-      if ($dbh->{Driver}->{Name} eq 'Pg') {
+    if($field eq 'signupdate' && defined $hour) {
+      if ($dbh->{Driver}->{Name} =~ /Pg/i) {
         push @where, "extract(hour from to_timestamp(cust_main.$field)) = $hour";
       }
         push @where, "extract(hour from to_timestamp(cust_main.$field)) = $hour";
       }
+      elsif( $dbh->{Driver}->{Name} =~ /mysql/i) {
+        push @where, "hour(from_unixtime(cust_main.$field)) = $hour"
+      }
       else {
         warn "search by time of day not supported on ".$dbh->{Driver}->{Name}." databases";
       }
       else {
         warn "search by time of day not supported on ".$dbh->{Driver}->{Name}." databases";
       }
@@ -656,6 +799,21 @@ sub search {
     push @where,
       "cust_main.custbatch = '$1'";
   }
     push @where,
       "cust_main.custbatch = '$1'";
   }
+  
+  if ( $params->{'tagnum'} ) {
+    my @tagnums = ref( $params->{'tagnum'} ) ? @{ $params->{'tagnum'} } : ( $params->{'tagnum'} );
+
+    @tagnums = grep /^(\d+)$/, @tagnums;
+
+    if ( @tagnums ) {
+       my $tags_where = "0 < (select count(1) from cust_tag where " 
+               . " cust_tag.custnum = cust_main.custnum and tagnum in ("
+               . join(',', @tagnums) . "))";
+
+       push @where, $tags_where;
+    }
+  }
+
 
   ##
   # setup queries, subs, etc. for the search
 
   ##
   # setup queries, subs, etc. for the search
@@ -664,11 +822,12 @@ sub search {
   $orderby ||= 'ORDER BY custnum';
 
   # here is the agent virtualization
   $orderby ||= 'ORDER BY custnum';
 
   # here is the agent virtualization
-  push @where, $FS::CurrentUser::CurrentUser->agentnums_sql;
+  push @where,
+    $FS::CurrentUser::CurrentUser->agentnums_sql(table => 'cust_main');
 
   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
 
 
   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
 
-  my $addl_from = 'LEFT JOIN cust_pkg USING ( custnum  ) ';
+  my $addl_from = '';
 
   my $count_query = "SELECT COUNT(*) FROM cust_main $extra_sql";
 
 
   my $count_query = "SELECT COUNT(*) FROM cust_main $extra_sql";
 
@@ -682,16 +841,21 @@ sub search {
 
   if ($params->{'flattened_pkgs'}) {
 
 
   if ($params->{'flattened_pkgs'}) {
 
+    #my $pkg_join = '';
+    $addl_from .=
+      ' LEFT JOIN cust_pkg ON ( cust_main.custnum = cust_pkg.custnum ) ';
+
     if ($dbh->{Driver}->{Name} eq 'Pg') {
 
       push @select, "array_to_string(array(select pkg from cust_pkg left join part_pkg using ( pkgpart ) where cust_main.custnum = cust_pkg.custnum $pkgwhere),'|') as magic";
 
     if ($dbh->{Driver}->{Name} eq 'Pg') {
 
       push @select, "array_to_string(array(select pkg from cust_pkg left join part_pkg using ( pkgpart ) where cust_main.custnum = cust_pkg.custnum $pkgwhere),'|') as magic";
 
-    }elsif ($dbh->{Driver}->{Name} =~ /^mysql/i) {
-      push @select, "GROUP_CONCAT(pkg SEPARATOR '|') as magic";
-      $addl_from .= " LEFT JOIN part_pkg using ( pkgpart )";
-    }else{
+    } elsif ($dbh->{Driver}->{Name} =~ /^mysql/i) {
+      push @select, "GROUP_CONCAT(part_pkg.pkg SEPARATOR '|') as magic";
+      $addl_from .= ' LEFT JOIN part_pkg USING ( pkgpart ) ';
+      #$pkg_join  .= ' LEFT JOIN part_pkg USING ( pkgpart ) ';
+    } else {
       warn "warning: unknown database type ". $dbh->{Driver}->{Name}. 
       warn "warning: unknown database type ". $dbh->{Driver}->{Name}. 
-           "omitting packing information from report.";
+           "omitting package information from report.";
     }
 
     my $header_query = "SELECT COUNT(cust_pkg.custnum = cust_main.custnum) AS count FROM cust_main $addl_from $extra_sql $pkgwhere group by cust_main.custnum order by count desc limit 1";
     }
 
     my $header_query = "SELECT COUNT(cust_pkg.custnum = cust_main.custnum) AS count FROM cust_main $addl_from $extra_sql $pkgwhere group by cust_main.custnum order by count desc limit 1";
@@ -729,6 +893,7 @@ sub search {
   my $sql_query = {
     'table'         => 'cust_main',
     'select'        => $select,
   my $sql_query = {
     'table'         => 'cust_main',
     'select'        => $select,
+    'addl_from'     => $addl_from,
     'hashref'       => {},
     'extra_sql'     => $extra_sql,
     'order_by'      => $orderby,
     'hashref'       => {},
     'extra_sql'     => $extra_sql,
     'order_by'      => $orderby,
@@ -793,7 +958,7 @@ sub fuzzy_search {
 
 sub check_and_rebuild_fuzzyfiles {
   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
 
 sub check_and_rebuild_fuzzyfiles {
   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
-  rebuild_fuzzyfiles() if grep { ! -e "$dir/cust_main.$_" } @fuzzyfields
+  rebuild_fuzzyfiles() if grep { ! -e "$dir/cust_main.$_" } @fuzzyfields;
 }
 
 =item rebuild_fuzzyfiles
 }
 
 =item rebuild_fuzzyfiles
@@ -814,7 +979,7 @@ sub rebuild_fuzzyfiles {
     flock(LOCK,LOCK_EX)
       or die "can't lock $dir/cust_main.$fuzzy: $!";
 
     flock(LOCK,LOCK_EX)
       or die "can't lock $dir/cust_main.$fuzzy: $!";
 
-    open (CACHE,">$dir/cust_main.$fuzzy.tmp")
+    open (CACHE, '>:encoding(UTF-8)', "$dir/cust_main.$fuzzy.tmp")
       or die "can't open $dir/cust_main.$fuzzy.tmp: $!";
 
     foreach my $field ( $fuzzy, "ship_$fuzzy" ) {
       or die "can't open $dir/cust_main.$fuzzy.tmp: $!";
 
     foreach my $field ( $fuzzy, "ship_$fuzzy" ) {
@@ -836,6 +1001,41 @@ sub rebuild_fuzzyfiles {
 
 }
 
 
 }
 
+=item append_fuzzyfiles FIRSTNAME LASTNAME COMPANY ADDRESS1
+
+=cut
+
+sub append_fuzzyfiles {
+  #my( $first, $last, $company ) = @_;
+
+  check_and_rebuild_fuzzyfiles();
+
+  use Fcntl qw(:flock);
+
+  my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
+
+  foreach my $field (@fuzzyfields) {
+    my $value = shift;
+
+    if ( $value ) {
+
+      open(CACHE, '>>:encoding(UTF-8)', "$dir/cust_main.$field" )
+        or die "can't open $dir/cust_main.$field: $!";
+      flock(CACHE,LOCK_EX)
+        or die "can't lock $dir/cust_main.$field: $!";
+
+      print CACHE "$value\n";
+
+      flock(CACHE,LOCK_UN)
+        or die "can't unlock $dir/cust_main.$field: $!";
+      close CACHE;
+    }
+
+  }
+
+  1;
+}
+
 =item all_X
 
 =cut
 =item all_X
 
 =cut
@@ -843,7 +1043,7 @@ sub rebuild_fuzzyfiles {
 sub all_X {
   my( $self, $field ) = @_;
   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
 sub all_X {
   my( $self, $field ) = @_;
   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
-  open(CACHE,"<$dir/cust_main.$field")
+  open(CACHE, '<:encoding(UTF-8)', "$dir/cust_main.$field")
     or die "can't open $dir/cust_main.$field: $!";
   my @array = map { chomp; $_; } <CACHE>;
   close CACHE;
     or die "can't open $dir/cust_main.$field: $!";
   my @array = map { chomp; $_; } <CACHE>;
   close CACHE;