543ef1a619d8eb74d6fb1cb7e2c09ea828ac43ff
[freeside.git] / FS / FS / cust_pkg / Search.pm
1 package FS::cust_pkg::Search;
2
3 use strict;
4 use FS::CurrentUser;
5 use FS::UI::Web;
6 use FS::cust_main;
7 use FS::cust_pkg;
8
9 =item search HASHREF
10
11 (Class method)
12
13 Returns a qsearch hash expression to search for parameters specified in HASHREF.
14 Valid parameters are
15
16 =over 4
17
18 =item agentnum
19
20 =item magic
21
22 on hold, active, inactive (or one-time charge), suspended, cancel (or cancelled)
23
24 =item status
25
26 on hold, active, inactive (or one-time charge), suspended, cancel (or cancelled)
27
28 =item custom
29
30  boolean selects custom packages
31
32 =item classnum
33
34 =item pkgpart
35
36 pkgpart or arrayref or hashref of pkgparts
37
38 =item setup
39
40 arrayref of beginning and ending epoch date
41
42 =item last_bill
43
44 arrayref of beginning and ending epoch date
45
46 =item bill
47
48 arrayref of beginning and ending epoch date
49
50 =item adjourn
51
52 arrayref of beginning and ending epoch date
53
54 =item susp
55
56 arrayref of beginning and ending epoch date
57
58 =item expire
59
60 arrayref of beginning and ending epoch date
61
62 =item cancel
63
64 arrayref of beginning and ending epoch date
65
66 =item query
67
68 pkgnum or APKG_pkgnum
69
70 =item cust_fields
71
72 a value suited to passing to FS::UI::Web::cust_header
73
74 =item CurrentUser
75
76 specifies the user for agent virtualization
77
78 =item fcc_line
79
80 boolean; if true, returns only packages with more than 0 FCC phone lines.
81
82 =item state, country
83
84 Limit to packages with a service location in the specified state and country.
85 For FCC 477 reporting, mostly.
86
87 =item location_cust
88
89 Limit to packages whose service locations are the same as the customer's 
90 default service location.
91
92 =item location_nocust
93
94 Limit to packages whose service locations are not the customer's default 
95 service location.
96
97 =item location_census
98
99 Limit to packages whose service locations have census tracts.
100
101 =item location_nocensus
102
103 Limit to packages whose service locations do not have a census tract.
104
105 =item location_geocode
106
107 Limit to packages whose locations have geocodes.
108
109 =item location_geocode
110
111 Limit to packages whose locations do not have geocodes.
112
113 =back
114
115 =cut
116
117 sub search {
118   my ($class, $params) = @_;
119   my @where = ();
120
121   ##
122   # parse agent
123   ##
124
125   if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
126     push @where,
127       "cust_main.agentnum = $1";
128   }
129
130   ##
131   # parse cust_status
132   ##
133
134   if ( $params->{'cust_status'} =~ /^([a-z]+)$/ ) {
135     push @where, FS::cust_main->cust_status_sql . " = '$1' ";
136   }
137
138   ##
139   # parse customer sales person
140   ##
141
142   if ( $params->{'cust_main_salesnum'} =~ /^(\d+)$/ ) {
143     push @where, ($1 > 0) ? "cust_main.salesnum = $1"
144                           : 'cust_main.salesnum IS NULL';
145   }
146
147
148   ##
149   # parse sales person
150   ##
151
152   if ( $params->{'salesnum'} =~ /^(\d+)$/ ) {
153     push @where, ($1 > 0) ? "cust_pkg.salesnum = $1"
154                           : 'cust_pkg.salesnum IS NULL';
155   }
156
157   ##
158   # parse custnum
159   ##
160
161   if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
162     push @where,
163       "cust_pkg.custnum = $1";
164   }
165
166   ##
167   # custbatch
168   ##
169
170   if ( $params->{'pkgbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
171     push @where,
172       "cust_pkg.pkgbatch = '$1'";
173   }
174
175   ##
176   # parse status
177   ##
178
179   if (    $params->{'magic'}  eq 'active'
180        || $params->{'status'} eq 'active' ) {
181
182     push @where, FS::cust_pkg->active_sql();
183
184   } elsif (    $params->{'magic'}  =~ /^not[ _]yet[ _]billed$/
185             || $params->{'status'} =~ /^not[ _]yet[ _]billed$/ ) {
186
187     push @where, FS::cust_pkg->not_yet_billed_sql();
188
189   } elsif (    $params->{'magic'}  =~ /^(one-time charge|inactive)/
190             || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
191
192     push @where, FS::cust_pkg->inactive_sql();
193
194   } elsif (    $params->{'magic'}  =~ /^on[ _]hold$/
195             || $params->{'status'} =~ /^on[ _]hold$/ ) {
196
197     push @where, FS::cust_pkg->on_hold_sql();
198
199
200   } elsif (    $params->{'magic'}  eq 'suspended'
201             || $params->{'status'} eq 'suspended'  ) {
202
203     push @where, FS::cust_pkg->suspended_sql();
204
205   } elsif (    $params->{'magic'}  =~ /^cancell?ed$/
206             || $params->{'status'} =~ /^cancell?ed$/ ) {
207
208     push @where, FS::cust_pkg->cancelled_sql();
209
210   }
211
212   ###
213   # parse package class
214   ###
215
216   if ( exists($params->{'classnum'}) ) {
217
218     my @classnum = ();
219     if ( ref($params->{'classnum'}) ) {
220
221       if ( ref($params->{'classnum'}) eq 'HASH' ) {
222         @classnum = grep $params->{'classnum'}{$_}, keys %{ $params->{'classnum'} };
223       } elsif ( ref($params->{'classnum'}) eq 'ARRAY' ) {
224         @classnum = @{ $params->{'classnum'} };
225       } else {
226         die 'unhandled classnum ref '. $params->{'classnum'};
227       }
228
229
230     } elsif ( $params->{'classnum'} =~ /^(\d*)$/ && $1 ne '0' ) {
231       @classnum = ( $1 );
232     }
233
234     if ( @classnum ) {
235
236       my @c_where = ();
237       my @nums = grep $_, @classnum;
238       push @c_where, 'part_pkg.classnum IN ('. join(',',@nums). ')' if @nums;
239       my $null = scalar( grep { $_ eq '' } @classnum );
240       push @c_where, 'part_pkg.classnum IS NULL' if $null;
241
242       if ( scalar(@c_where) == 1 ) {
243         push @where, @c_where;
244       } elsif ( @c_where ) {
245         push @where, ' ( '. join(' OR ', @c_where). ' ) ';
246       }
247
248     }
249     
250
251   }
252
253   ###
254   # parse package report options
255   ###
256
257   my @report_option = ();
258   if ( exists($params->{'report_option'}) ) {
259     if ( ref($params->{'report_option'}) eq 'ARRAY' ) {
260       @report_option = @{ $params->{'report_option'} };
261     } elsif ( $params->{'report_option'} =~ /^([,\d]*)$/ ) {
262       @report_option = split(',', $1);
263     }
264
265   }
266
267   if (@report_option) {
268     # this will result in the empty set for the dangling comma case as it should
269     push @where, 
270       map{ "0 < ( SELECT count(*) FROM part_pkg_option
271                     WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
272                     AND optionname = 'report_option_$_'
273                     AND optionvalue = '1' )"
274          } @report_option;
275   }
276
277   foreach my $any ( grep /^report_option_any/, keys %$params ) {
278
279     my @report_option_any = ();
280     if ( ref($params->{$any}) eq 'ARRAY' ) {
281       @report_option_any = @{ $params->{$any} };
282     } elsif ( $params->{$any} =~ /^([,\d]*)$/ ) {
283       @report_option_any = split(',', $1);
284     }
285
286     if (@report_option_any) {
287       # this will result in the empty set for the dangling comma case as it should
288       push @where, ' ( '. join(' OR ',
289         map{ "0 < ( SELECT count(*) FROM part_pkg_option
290                       WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
291                       AND optionname = 'report_option_$_'
292                       AND optionvalue = '1' )"
293            } @report_option_any
294       ). ' ) ';
295     }
296
297   }
298
299   ###
300   # parse custom
301   ###
302
303   push @where,  "part_pkg.custom = 'Y'" if $params->{custom};
304
305   ###
306   # parse fcc_line
307   ###
308
309   push @where,  "(part_pkg.fcc_ds0s > 0 OR pkg_class.fcc_ds0s > 0)" 
310                                                         if $params->{fcc_line};
311
312   ###
313   # parse censustract
314   ###
315
316   if ( exists($params->{'censustract'}) ) {
317     $params->{'censustract'} =~ /^([.\d]*)$/;
318     my $censustract = "cust_location.censustract = '$1'";
319     $censustract .= ' OR cust_location.censustract is NULL' unless $1;
320     push @where,  "( $censustract )";
321   }
322
323   ###
324   # parse censustract2
325   ###
326   if ( exists($params->{'censustract2'})
327        && $params->{'censustract2'} =~ /^(\d*)$/
328      )
329   {
330     if ($1) {
331       push @where, "cust_location.censustract LIKE '$1%'";
332     } else {
333       push @where,
334         "( cust_location.censustract = '' OR cust_location.censustract IS NULL )";
335     }
336   }
337
338   ###
339   # parse country/state
340   ###
341   for (qw(state country)) { # parsing rules are the same for these
342   if ( exists($params->{$_}) 
343     && uc($params->{$_}) =~ /^([A-Z]{2})$/ )
344     {
345       # XXX post-2.3 only--before that, state/country may be in cust_main
346       push @where, "cust_location.$_ = '$1'";
347     }
348   }
349
350   ###
351   # location_* flags
352   ###
353   if ( $params->{location_cust} xor $params->{location_nocust} ) {
354     my $op = $params->{location_cust} ? '=' : '!=';
355     push @where, "cust_location.locationnum $op cust_main.ship_locationnum";
356   }
357   if ( $params->{location_census} xor $params->{location_nocensus} ) {
358     my $op = $params->{location_census} ? "IS NOT NULL" : "IS NULL";
359     push @where, "cust_location.censustract $op";
360   }
361   if ( $params->{location_geocode} xor $params->{location_nogeocode} ) {
362     my $op = $params->{location_geocode} ? "IS NOT NULL" : "IS NULL";
363     push @where, "cust_location.geocode $op";
364   }
365
366   ###
367   # parse part_pkg
368   ###
369
370   if ( ref($params->{'pkgpart'}) ) {
371
372     my @pkgpart = ();
373     if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
374       @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
375     } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
376       @pkgpart = @{ $params->{'pkgpart'} };
377     } else {
378       die 'unhandled pkgpart ref '. $params->{'pkgpart'};
379     }
380
381     @pkgpart = grep /^(\d+)$/, @pkgpart;
382
383     push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
384
385   } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
386     push @where, "pkgpart = $1";
387   } 
388
389   ###
390   # parse dates
391   ###
392
393   my $orderby = '';
394
395   #false laziness w/report_cust_pkg.html
396   my %disable = (
397     'all'             => {},
398     'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
399     'active'          => { 'susp'=>1, 'cancel'=>1 },
400     'suspended'       => { 'cancel' => 1 },
401     'cancelled'       => {},
402     ''                => {},
403   );
404
405   if( exists($params->{'active'} ) ) {
406     # This overrides all the other date-related fields, and includes packages
407     # that were active at some time during the interval.  It excludes:
408     # - packages that were set up after the end of the interval
409     # - packages that were canceled before the start of the interval
410     # - packages that were suspended before the start of the interval
411     #   and are still suspended now
412     my($beginning, $ending) = @{$params->{'active'}};
413     push @where,
414       "cust_pkg.setup IS NOT NULL",
415       "cust_pkg.setup <= $ending",
416       "(cust_pkg.cancel IS NULL OR cust_pkg.cancel >= $beginning )",
417       "(cust_pkg.susp   IS NULL OR cust_pkg.susp   >= $beginning )",
418       "NOT (".FS::cust_pkg->onetime_sql . ")";
419   }
420   else {
421     foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end change_date cancel )) {
422
423       next unless exists($params->{$field});
424
425       my($beginning, $ending) = @{$params->{$field}};
426
427       next if $beginning == 0 && $ending == 4294967295;
428
429       push @where,
430         "cust_pkg.$field IS NOT NULL",
431         "cust_pkg.$field >= $beginning",
432         "cust_pkg.$field <= $ending";
433
434       $orderby ||= "ORDER BY cust_pkg.$field";
435
436     }
437   }
438
439   $orderby ||= 'ORDER BY bill';
440
441   ###
442   # parse magic, legacy, etc.
443   ###
444
445   if ( $params->{'magic'} &&
446        $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
447   ) {
448
449     $orderby = 'ORDER BY pkgnum';
450
451     if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
452       push @where, "pkgpart = $1";
453     }
454
455   } elsif ( $params->{'query'} eq 'pkgnum' ) {
456
457     $orderby = 'ORDER BY pkgnum';
458
459   } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
460
461     $orderby = 'ORDER BY pkgnum';
462
463     push @where, '0 < (
464       SELECT count(*) FROM pkg_svc
465        WHERE pkg_svc.pkgpart =  cust_pkg.pkgpart
466          AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
467                                    WHERE cust_svc.pkgnum  = cust_pkg.pkgnum
468                                      AND cust_svc.svcpart = pkg_svc.svcpart
469                                 )
470     )';
471   
472   }
473
474   ##
475   # setup queries, links, subs, etc. for the search
476   ##
477
478   # here is the agent virtualization
479   if ($params->{CurrentUser}) {
480     my $access_user =
481       qsearchs('access_user', { username => $params->{CurrentUser} });
482
483     if ($access_user) {
484       push @where, $access_user->agentnums_sql('table'=>'cust_main');
485     } else {
486       push @where, "1=0";
487     }
488   } else {
489     push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
490   }
491
492   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
493
494   my $addl_from = 'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
495                   'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) '.
496                   'LEFT JOIN cust_location USING ( locationnum ) '.
497                   FS::UI::Web::join_cust_main('cust_pkg', 'cust_pkg');
498
499   my $select;
500   my $count_query;
501   if ( $params->{'select_zip5'} ) {
502     my $zip = 'cust_location.zip';
503
504     $select = "DISTINCT substr($zip,1,5) as zip";
505     $orderby = "ORDER BY substr($zip,1,5)";
506     $count_query = "SELECT COUNT( DISTINCT substr($zip,1,5) )";
507   } else {
508     $select = join(', ',
509                          'cust_pkg.*',
510                          ( map "part_pkg.$_", qw( pkg freq ) ),
511                          'pkg_class.classname',
512                          'cust_main.custnum AS cust_main_custnum',
513                          FS::UI::Web::cust_sql_fields(
514                            $params->{'cust_fields'}
515                          ),
516                   );
517     $count_query = 'SELECT COUNT(*)';
518   }
519
520   $count_query .= " FROM cust_pkg $addl_from $extra_sql";
521
522   my $sql_query = {
523     'table'       => 'cust_pkg',
524     'hashref'     => {},
525     'select'      => $select,
526     'extra_sql'   => $extra_sql,
527     'order_by'    => $orderby,
528     'addl_from'   => $addl_from,
529     'count_query' => $count_query,
530   };
531
532 }
533
534 1;
535