1 package FS::cust_main::Search;
4 use base qw( Exporter );
5 use vars qw( @EXPORT_OK $DEBUG $me $conf @fuzzyfields );
6 use String::Approx qw(amatch);
8 use FS::Record qw( qsearch );
10 use FS::cust_main_invoice;
13 @EXPORT_OK = qw( smart_search );
15 # 1 is mostly method/subroutine entry and options
16 # 2 traces progress of some operations
17 # 3 is even more information including possibly sensitive data
19 $me = '[FS::cust_main::Search]';
21 @fuzzyfields = ( 'first', 'last', 'company', 'address1' );
23 install_callback FS::UID sub {
25 #yes, need it for stuff below (prolly should be cached)
30 FS::cust_main::Search - Customer searching
34 use FS::cust_main::Search;
36 FS::cust_main::Search::smart_search(%options);
38 FS::cust_main::Search::email_search(%options);
40 FS::cust_main::Search->search( \%options );
42 FS::cust_main::Search->fuzzy_search( \%fuzzy_hashref );
48 =item smart_search OPTION => VALUE ...
50 Accepts the following options: I<search>, the string to search for. The string
51 will be searched for as a customer number, phone number, name or company name,
52 as an exact, or, in some cases, a substring or fuzzy match (see the source code
53 for the exact heuristics used); I<no_fuzzy_on_exact>, causes smart_search to
54 skip fuzzy matching when an exact match is found.
56 Any additional options are treated as an additional qualifier on the search
59 Returns a (possibly empty) array of FS::cust_main objects.
66 #here is the agent virtualization
68 $FS::CurrentUser::CurrentUser->agentnums_sql(table => 'cust_main');
72 my $skip_fuzzy = delete $options{'no_fuzzy_on_exact'};
73 my $search = delete $options{'search'};
74 ( my $alphanum_search = $search ) =~ s/\W//g;
76 if ( $alphanum_search =~ /^1?(\d{3})(\d{3})(\d{4})(\d*)$/ ) { #phone# search
78 #false laziness w/Record::ut_phone
79 my $phonen = "$1-$2-$3";
80 $phonen .= " x$4" if $4;
82 push @cust_main, qsearch( {
83 'table' => 'cust_main',
84 'hashref' => { %options },
85 'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
87 join(' OR ', map "$_ = '$phonen'",
88 qw( daytime night fax )
91 " AND $agentnums_sql", #agent virtualization
94 unless ( @cust_main || $phonen =~ /x\d+$/ ) { #no exact match
95 #try looking for matches with extensions unless one was specified
97 push @cust_main, qsearch( {
98 'table' => 'cust_main',
99 'hashref' => { %options },
100 'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
102 join(' OR ', map "$_ LIKE '$phonen\%'",
106 " AND $agentnums_sql", #agent virtualization
111 # custnum search (also try agent_custid), with some tweaking options if your
112 # legacy cust "numbers" have letters
116 if ( $search =~ /@/ ) {
120 'table' => 'cust_main_invoice',
121 'hashref' => { 'dest' => $search },
124 } elsif ( $search =~ /^\s*(\d+)\s*$/
125 || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+'
126 && $search =~ /^\s*(\w\w?\d+)\s*$/
128 || ( $conf->config('cust_main-custnum-display_special')
129 # it's not currently possible for special prefixes to contain
130 # digits, so just strip off any alphabetic prefix and match
131 # the rest to custnum
132 && $search =~ /^\s*[[:alpha:]]*(\d+)\s*$/
134 || ( $conf->exists('address1-search' )
135 && $search =~ /^\s*(\d+\-?\w*)\s*$/ #i.e. 1234A or 9432-D
142 if ( $num =~ /^(\d+)$/ && $num <= 2147483647 ) { #need a bigint custnum? wow
143 push @cust_main, qsearch( {
144 'table' => 'cust_main',
145 'hashref' => { 'custnum' => $num, %options },
146 'extra_sql' => " AND $agentnums_sql", #agent virtualization
150 # for all agents this user can see, if any of them have custnum prefixes
151 # that match the search string, include customers that match the rest
152 # of the custnum and belong to that agent
153 foreach my $agentnum ( $FS::CurrentUser::CurrentUser->agentnums ) {
154 my $p = $conf->config('cust_main-custnum-display_prefix', $agentnum);
156 if ( $p eq substr($num, 0, length($p)) ) {
157 push @cust_main, qsearch( {
158 'table' => 'cust_main',
159 'hashref' => { 'custnum' => 0 + substr($num, length($p)),
160 'agentnum' => $agentnum,
167 push @cust_main, qsearch( {
168 'table' => 'cust_main',
169 'hashref' => { 'agent_custid' => $num, %options },
170 'extra_sql' => " AND $agentnums_sql", #agent virtualization
173 if ( $conf->exists('address1-search') ) {
174 my $len = length($num);
176 # probably the Right Thing: return customers that have any associated
177 # locations matching the string, not just bill/ship location
178 push @cust_main, qsearch( {
179 'table' => 'cust_main',
180 'addl_from' => ' JOIN cust_location USING (custnum) ',
181 'hashref' => { %options, },
183 ( keys(%options) ? ' AND ' : ' WHERE ' ).
184 " LOWER(SUBSTRING(cust_location.address1 FROM 1 FOR $len)) = '$num' ".
185 " AND $agentnums_sql",
189 } elsif ( $search =~ /^\s*(\S.*\S)\s+\((.+), ([^,]+)\)\s*$/ ) {
191 my($company, $last, $first) = ( $1, $2, $3 );
193 # "Company (Last, First)"
194 #this is probably something a browser remembered,
195 #so just do an exact search (but case-insensitive, so USPS standardization
196 #doesn't throw a wrench in the works)
198 push @cust_main, qsearch( {
199 'table' => 'cust_main',
200 'hashref' => { %options },
202 ( keys(%options) ? ' AND ' : ' WHERE ' ).
204 " LOWER(first) = ". dbh->quote(lc($first)),
205 " LOWER(last) = ". dbh->quote(lc($last)),
206 " LOWER(company) = ". dbh->quote(lc($company)),
212 } elsif ( $search =~ /^\s*(\S.*\S)\s*$/ ) { # value search
213 # try (ship_){last,company}
217 # # remove "(Last, First)" in "Company (Last, First)", otherwise the
218 # # full strings the browser remembers won't work
219 # $value =~ s/\([\w \,\.\-\']*\)$//; #false laziness w/Record::ut_name
221 use Lingua::EN::NameParse;
222 my $NameParse = new Lingua::EN::NameParse(
227 my($last, $first) = ( '', '' );
228 #maybe disable this too and just rely on NameParse?
229 if ( $value =~ /^(.+),\s*([^,]+)$/ ) { # Last, First
231 ($last, $first) = ( $1, $2 );
233 #} elsif ( $value =~ /^(.+)\s+(.+)$/ ) {
234 } elsif ( ! $NameParse->parse($value) ) {
236 my %name = $NameParse->components;
237 $first = $name{'given_name_1'} || $name{'initials_1'}; #wtf NameParse, Ed?
238 $last = $name{'surname_1'};
242 if ( $first && $last ) {
244 my($q_last, $q_first) = ( dbh->quote($last), dbh->quote($first) );
247 my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
248 $sql .= "( LOWER(cust_main.last) = $q_last AND LOWER(cust_main.first) = $q_first )";
250 push @cust_main, qsearch( {
251 'table' => 'cust_main',
252 'hashref' => \%options,
253 'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
257 # or it just be something that was typed in... (try that in a sec)
261 my $q_value = dbh->quote($value);
264 my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
265 $sql .= " ( LOWER(last) = $q_value
266 OR LOWER(company) = $q_value
270 SELECT 1 FROM cust_location
271 WHERE LOWER(cust_location.address1) = $q_value
272 AND cust_location.custnum = cust_main.custnum
275 if $conf->exists('address1-search');
278 push @cust_main, qsearch( {
279 'table' => 'cust_main',
280 'hashref' => \%options,
281 'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
284 #no exact match, trying substring/fuzzy
285 #always do substring & fuzzy (unless they're explicity config'ed off)
286 #getting complaints searches are not returning enough
287 unless ( @cust_main && $skip_fuzzy || $conf->exists('disable-fuzzy') ) {
289 #still some false laziness w/search (was search/cust_main.cgi)
294 { 'company' => { op=>'ILIKE', value=>"%$value%" }, },
297 if ( $first && $last ) {
298 #contacts? ship_first/ship_last are gone
301 { 'first' => { op=>'ILIKE', value=>"%$first%" },
302 'last' => { op=>'ILIKE', value=>"%$last%" },
309 { 'last' => { op=>'ILIKE', value=>"%$value%" }, },
313 foreach my $hashref ( @hashrefs ) {
315 push @cust_main, qsearch( {
316 'table' => 'cust_main',
317 'hashref' => { %$hashref,
320 'extra_sql' => " AND $agentnums_sql", #agent virtualizaiton
325 if ( $conf->exists('address1-search') ) {
327 push @cust_main, qsearch( {
328 'table' => 'cust_main',
329 'addl_from' => 'JOIN cust_location USING (custnum)',
330 'extra_sql' => 'WHERE cust_location.address1 ILIKE '.
331 dbh->quote("%$value%"),
338 'hashref' => \%options,
340 'extra_sql' => " AND $agentnums_sql", #agent virtualization
343 if ( $first && $last ) {
344 push @cust_main, FS::cust_main::Search->fuzzy_search(
345 { 'last' => $last, #fuzzy hashref
346 'first' => $first }, #
350 foreach my $field ( 'last', 'company' ) {
352 FS::cust_main::Search->fuzzy_search( { $field => $value }, %fuzopts );
354 if ( $conf->exists('address1-search') ) {
356 FS::cust_main::Search->fuzzy_search( { 'address1' => $value }, %fuzopts );
363 #eliminate duplicates
365 @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
373 Accepts the following options: I<email>, the email address to search for. The
374 email address will be searched for as an email invoice destination and as an
377 #Any additional options are treated as an additional qualifier on the search
380 Returns a (possibly empty) array of FS::cust_main objects (but usually just
390 my $email = delete $options{'email'};
392 #we're only being used by RT at the moment... no agent virtualization yet
393 #my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql;
397 if ( $email =~ /([^@]+)\@([^@]+)/ ) {
399 my ( $user, $domain ) = ( $1, $2 );
401 warn "$me smart_search: searching for $user in domain $domain"
407 'table' => 'cust_main_invoice',
408 'hashref' => { 'dest' => $email },
415 map $_->cust_svc->cust_pkg,
417 'table' => 'svc_acct',
418 'hashref' => { 'username' => $user, },
420 'AND ( SELECT domain FROM svc_domain
421 WHERE svc_acct.domsvc = svc_domain.svcnum
422 ) = '. dbh->quote($domain),
428 @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
430 warn "$me smart_search: found ". scalar(@cust_main). " unique customers"
447 Returns a qsearch hash expression to search for parameters specified in
448 HASHREF. Valid parameters are
466 listref of start date, end date
470 listref of start date, end date
472 =item spouse_birthdate
474 listref of start date, end date
484 =item current_balance
486 listref (list returned by FS::UI::Web::parse_lt_gt($cgi, 'current_balance'))
499 my ($class, $params) = @_;
506 # initialize these to prevent warnings
513 'paydate_year' => '',
514 'invoice_terms' => '',
520 # explicit custnum(s)
523 if ( $params->{'custnum'} ) {
524 my @custnums = ref($params->{'custnum'}) ?
525 @{ $params->{'custnum'} } :
526 $params->{'custnum'};
528 'cust_main.custnum IN (' .
529 join(',', map { $_ =~ /^(\d+)$/ ? $1 : () } @custnums ) .
530 ')' if scalar(@custnums) > 0;
537 if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
539 "cust_main.agentnum = $1";
543 # do the same for user
546 if ( $params->{'usernum'} =~ /^(\d+)$/ and $1 ) {
548 "cust_main.usernum = $1";
555 #prospect ordered active inactive suspended cancelled
556 if ( grep { $params->{'status'} eq $_ } FS::cust_main->statuses() ) {
557 my $method = $params->{'status'}. '_sql';
558 #push @where, $class->$method();
559 push @where, FS::cust_main->$method();
565 if ( $params->{'address'} =~ /\S/ ) {
566 my $address = dbh->quote('%'. lc($params->{'address'}). '%');
567 push @where, "EXISTS(
568 SELECT 1 FROM cust_location
569 WHERE cust_location.custnum = cust_main.custnum
570 AND (LOWER(cust_location.address1) LIKE $address OR
571 LOWER(cust_location.address2) LIKE $address)
578 if ( $params->{'refnum'} =~ /^(\d+)$/ ) {
579 push @where, "refnum = $1";
583 # parse cancelled package checkbox
588 $pkgwhere .= "AND (cancel = 0 or cancel is null)"
589 unless $params->{'cancelled_pkgs'};
592 # parse without census tract checkbox
595 push @where, "(censustract = '' or censustract is null)"
596 if $params->{'no_censustract'};
599 # parse with hardcoded tax location checkbox
602 push @where, "geocode is not null"
603 if $params->{'with_geocode'};
609 foreach my $field (qw( signupdate birthdate spouse_birthdate )) {
611 next unless exists($params->{$field});
613 my($beginning, $ending, $hour) = @{$params->{$field}};
616 "cust_main.$field IS NOT NULL",
617 "cust_main.$field >= $beginning",
618 "cust_main.$field <= $ending";
620 if($field eq 'signupdate' && defined $hour) {
621 if ($dbh->{Driver}->{Name} =~ /Pg/i) {
622 push @where, "extract(hour from to_timestamp(cust_main.$field)) = $hour";
624 elsif( $dbh->{Driver}->{Name} =~ /mysql/i) {
625 push @where, "hour(from_unixtime(cust_main.$field)) = $hour"
628 warn "search by time of day not supported on ".$dbh->{Driver}->{Name}." databases";
632 $orderby ||= "ORDER BY cust_main.$field";
640 if ( $params->{'classnum'} ) {
642 my @classnum = ref( $params->{'classnum'} )
643 ? @{ $params->{'classnum'} }
644 : ( $params->{'classnum'} );
646 @classnum = grep /^(\d*)$/, @classnum;
649 push @where, '( '. join(' OR ', map {
650 $_ ? "cust_main.classnum = $_"
651 : "cust_main.classnum IS NULL"
664 if ( $params->{'payby'} ) {
666 my @payby = ref( $params->{'payby'} )
667 ? @{ $params->{'payby'} }
668 : ( $params->{'payby'} );
670 @payby = grep /^([A-Z]{4})$/, @payby;
672 push @where, '( '. join(' OR ', map "cust_main.payby = '$_'", @payby). ' )'
678 # paydate_year / paydate_month
681 if ( $params->{'paydate_year'} =~ /^(\d{4})$/ ) {
683 $params->{'paydate_month'} =~ /^(\d\d?)$/
684 or die "paydate_year without paydate_month?";
688 'paydate IS NOT NULL',
690 "CAST(paydate AS timestamp) < CAST('$year-$month-01' AS timestamp )"
698 if ( $params->{'invoice_terms'} =~ /^([\w ]+)$/ ) {
700 if ( $1 eq 'NULL' ) {
702 "( cust_main.invoice_terms IS NULL OR cust_main.invoice_terms = '' )";
705 "cust_main.invoice_terms IS NOT NULL",
706 "cust_main.invoice_terms = '$1'";
714 if ( $params->{'current_balance'} ) {
716 #my $balance_sql = $class->balance_sql();
717 my $balance_sql = FS::cust_main->balance_sql();
719 my @current_balance =
720 ref( $params->{'current_balance'} )
721 ? @{ $params->{'current_balance'} }
722 : ( $params->{'current_balance'} );
724 push @where, map { s/current_balance/$balance_sql/; $_ }
733 if ( $params->{'custbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
735 "cust_main.custbatch = '$1'";
738 if ( $params->{'tagnum'} ) {
739 my @tagnums = ref( $params->{'tagnum'} ) ? @{ $params->{'tagnum'} } : ( $params->{'tagnum'} );
741 @tagnums = grep /^(\d+)$/, @tagnums;
744 my $tags_where = "0 < (select count(1) from cust_tag where "
745 . " cust_tag.custnum = cust_main.custnum and tagnum in ("
746 . join(',', @tagnums) . "))";
748 push @where, $tags_where;
754 # setup queries, subs, etc. for the search
757 $orderby ||= 'ORDER BY custnum';
759 # here is the agent virtualization
761 $FS::CurrentUser::CurrentUser->agentnums_sql(table => 'cust_main');
763 my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
767 my $count_query = "SELECT COUNT(*) FROM cust_main $extra_sql";
771 FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
774 my(@extra_headers) = ();
775 my(@extra_fields) = ();
777 if ($params->{'flattened_pkgs'}) {
780 $addl_from .= ' LEFT JOIN cust_pkg USING ( custnum ) ';
782 if ($dbh->{Driver}->{Name} eq 'Pg') {
787 SELECT pkg FROM cust_pkg LEFT JOIN part_pkg USING ( pkgpart )
788 WHERE cust_main.custnum = cust_pkg.custnum $pkgwhere
793 } elsif ($dbh->{Driver}->{Name} =~ /^mysql/i) {
794 push @select, "GROUP_CONCAT(part_pkg.pkg SEPARATOR '|') as magic";
795 $addl_from .= ' LEFT JOIN part_pkg USING ( pkgpart ) ';
796 #$pkg_join .= ' LEFT JOIN part_pkg USING ( pkgpart ) ';
798 warn "warning: unknown database type ". $dbh->{Driver}->{Name}.
799 "omitting package information from report.";
803 SELECT COUNT(cust_pkg.custnum = cust_main.custnum) AS count
804 FROM cust_main $addl_from $extra_sql $pkgwhere
805 GROUP BY cust_main.custnum ORDER BY count DESC LIMIT 1
808 my $sth = dbh->prepare($header_query) or die dbh->errstr;
809 $sth->execute() or die $sth->errstr;
810 my $headerrow = $sth->fetchrow_arrayref;
811 my $headercount = $headerrow ? $headerrow->[0] : 0;
812 while($headercount) {
813 unshift @extra_headers, "Package ". $headercount;
814 unshift @extra_fields, eval q!sub {my $c = shift;
815 my @a = split '\|', $c->magic;
816 my $p = $a[!.--$headercount. q!];
823 if ( $params->{'with_geocode'} ) {
825 unshift @extra_headers, 'Tax location override', 'Calculated tax location';
826 unshift @extra_fields, sub { my $c = shift; $c->get('geocode'); },
828 $c->set('geocode', '');
829 $c->geocode('cch'); #XXX only cch right now
831 push @select, 'geocode';
832 push @select, 'zip' unless grep { $_ eq 'zip' } @select;
833 push @select, 'ship_zip' unless grep { $_ eq 'ship_zip' } @select;
836 my $select = join(', ', @select);
839 'table' => 'cust_main',
841 'addl_from' => $addl_from,
843 'extra_sql' => $extra_sql,
844 'order_by' => $orderby,
845 'count_query' => $count_query,
846 'extra_headers' => \@extra_headers,
847 'extra_fields' => \@extra_fields,
852 =item fuzzy_search FUZZY_HASHREF [ OPTS ]
854 Performs a fuzzy (approximate) search and returns the matching FS::cust_main
855 records. Currently, I<first>, I<last>, I<company> and/or I<address1> may be
858 Additional options are the same as FS::Record::qsearch
863 my( $self, $fuzzy ) = @_;
864 # sensible defaults, then merge in any passed options
866 'table' => 'cust_main',
875 check_and_rebuild_fuzzyfiles();
876 foreach my $field ( keys %$fuzzy ) {
878 my $all = $self->all_X($field);
879 next unless scalar(@$all);
882 $match{$_}=1 foreach ( amatch( $fuzzy->{$field}, ['i'], @$all ) );
885 foreach ( keys %match ) {
886 if ( $field eq 'address1' ) {
887 #because it lives outside the table
888 my $addl_from = $fuzopts{addl_from} .
889 'JOIN cust_location USING (custnum)';
890 my $extra_sql = $fuzopts{extra_sql} .
891 " AND cust_location.address1 = ".dbh->quote($_);
892 push @fcust, qsearch({
894 'addl_from' => $addl_from,
895 'extra_sql' => $extra_sql,
898 my $hash = $fuzopts{hashref};
899 $hash->{$field} = $_;
900 push @fcust, qsearch({
907 push @cust_main, grep { ! $fsaw{$_->custnum}++ } @fcust;
910 # we want the components of $fuzzy ANDed, not ORed, but still don't want dupes
912 @cust_main = grep { ++$saw{$_->custnum} == scalar(keys %$fuzzy) } @cust_main;
920 =head1 UTILITY SUBROUTINES
924 =item check_and_rebuild_fuzzyfiles
928 sub check_and_rebuild_fuzzyfiles {
929 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
930 rebuild_fuzzyfiles() if grep { ! -e "$dir/cust_main.$_" } @fuzzyfields;
933 =item rebuild_fuzzyfiles
937 sub rebuild_fuzzyfiles {
939 use Fcntl qw(:flock);
941 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
942 mkdir $dir, 0700 unless -d $dir;
944 foreach my $fuzzy ( @fuzzyfields ) {
946 open(LOCK,">>$dir/cust_main.$fuzzy")
947 or die "can't open $dir/cust_main.$fuzzy: $!";
949 or die "can't lock $dir/cust_main.$fuzzy: $!";
951 open (CACHE, '>:encoding(UTF-8)', "$dir/cust_main.$fuzzy.tmp")
952 or die "can't open $dir/cust_main.$fuzzy.tmp: $!";
954 foreach my $field ( $fuzzy, "ship_$fuzzy" ) {
955 my $sth = dbh->prepare("SELECT $field FROM cust_main".
956 " WHERE $field != '' AND $field IS NOT NULL");
957 $sth->execute or die $sth->errstr;
959 while ( my $row = $sth->fetchrow_arrayref ) {
960 print CACHE $row->[0]. "\n";
965 close CACHE or die "can't close $dir/cust_main.$fuzzy.tmp: $!";
967 rename "$dir/cust_main.$fuzzy.tmp", "$dir/cust_main.$fuzzy";
973 =item append_fuzzyfiles FIRSTNAME LASTNAME COMPANY ADDRESS1
977 sub append_fuzzyfiles {
978 #my( $first, $last, $company ) = @_;
980 check_and_rebuild_fuzzyfiles();
982 use Fcntl qw(:flock);
984 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
986 foreach my $field (@fuzzyfields) {
991 open(CACHE, '>>:encoding(UTF-8)', "$dir/cust_main.$field" )
992 or die "can't open $dir/cust_main.$field: $!";
994 or die "can't lock $dir/cust_main.$field: $!";
996 print CACHE "$value\n";
999 or die "can't unlock $dir/cust_main.$field: $!";
1013 my( $self, $field ) = @_;
1014 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
1015 open(CACHE, '<:encoding(UTF-8)', "$dir/cust_main.$field")
1016 or die "can't open $dir/cust_main.$field: $!";
1017 my @array = map { chomp; $_; } <CACHE>;
1028 L<FS::cust_main>, L<FS::Record>