eef983d68339844b3d8752a3002ad6d580bc6240
[freeside.git] / FS / FS / Report / Table.pm
1 package FS::Report::Table;
2
3 use strict;
4 use base 'FS::Report';
5 use Time::Local qw( timelocal );
6 use FS::UID qw( dbh driver_name );
7 use FS::Report::Table;
8 use FS::CurrentUser;
9 use Cache::FileCache;
10
11 our $DEBUG = 0; # turning this on will trace all SQL statements, VERY noisy
12
13 our $CACHE; # feel free to use this for whatever
14
15 FS::UID->install_callback(sub {
16     $CACHE = Cache::FileCache->new( {
17       'namespace'   => __PACKAGE__,
18       'cache_root'  => "$FS::UID::cache_dir/cache.$FS::UID::datasrc",
19     } );
20     # reset this on startup (causes problems with database backups, etc.)
21     $CACHE->remove('tower_pkg_cache_update');
22 });
23
24 =head1 NAME
25
26 FS::Report::Table - Tables of report data
27
28 =head1 SYNOPSIS
29
30 See the more specific report objects, currently only 
31 FS::Report::Table::Monthly and FS::Report::Table::Daily.
32
33 =head1 OBSERVABLES
34
35 The common interface for an observable named 'foo' is:
36
37 $report->foo($startdate, $enddate, $agentnum, %options)
38
39 This returns a scalar value for foo, over the period from 
40 $startdate to $enddate, limited to agent $agentnum, subject to 
41 options in %opt.
42
43 =over 4
44
45 =item signups: The number of customers signed up.  Options are:
46
47 - cust_classnum: limit to this customer class
48 - pkg_classnum: limit to customers with a package of this class.  If this is
49   an arrayref, it's an ANY match.
50 - refnum: limit to this advertising source
51 - indirect: boolean; limit to customers that have a referral_custnum that
52   matches the advertising source
53
54 =cut
55
56 sub signups {
57   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
58   my @where = ( $self->in_time_period_and_agent($speriod, $eperiod, $agentnum, 
59       'cust_main.signupdate')
60   );
61   my $join = '';
62   if ( $opt{'indirect'} ) {
63     $join = " JOIN cust_main AS referring_cust_main".
64             " ON (cust_main.referral_custnum = referring_cust_main.custnum)";
65
66     if ( $opt{'refnum'} ) {
67       push @where, "referring_cust_main.refnum = ".$opt{'refnum'};
68     }
69   }
70   elsif ( $opt{'refnum'} ) {
71     push @where, "refnum = ".$opt{'refnum'};
72   }
73
74   push @where, $self->with_cust_classnum(%opt);
75   if ( $opt{'pkg_classnum'} ) {
76     my $classnum = $opt{'pkg_classnum'};
77     $classnum = [ $classnum ] unless ref $classnum;
78     @$classnum = grep /^\d+$/, @$classnum;
79     if (@$classnum) {
80       my $in = 'IN ('. join(',', @$classnum). ')';
81       push @where,
82         "EXISTS(SELECT 1 FROM cust_pkg JOIN part_pkg USING (pkgpart) ".
83                "WHERE cust_pkg.custnum = cust_main.custnum ".
84                "AND part_pkg.classnum $in".
85                ")";
86     }
87   }
88
89   $self->scalar_sql(
90     "SELECT COUNT(*) FROM cust_main $join WHERE ".join(' AND ', @where)
91   );
92 }
93
94 =item invoiced: The total amount charged on all invoices.
95
96 =cut
97
98 sub invoiced { #invoiced
99   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
100
101   my $sql = 'SELECT SUM(cust_bill.charged) FROM cust_bill';
102   if ( $opt{'setuprecur'} ) {
103     $sql = 'SELECT SUM('.
104             FS::cust_bill_pkg->charged_sql($speriod, $eperiod, %opt).
105            ') FROM cust_bill_pkg JOIN cust_bill USING (invnum)';
106   }
107
108   $self->scalar_sql("
109       $sql
110         LEFT JOIN cust_main USING ( custnum )
111       WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum).
112                $self->for_opts(%opt)
113   );
114   
115 }
116
117 =item netsales: invoiced - netcredits
118
119 =cut
120
121 sub netsales { #net sales
122   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
123
124     $self->invoiced(  $speriod, $eperiod, $agentnum, %opt)
125   - $self->netcredits($speriod, $eperiod, $agentnum, %opt);
126 }
127
128 =item cashflow: payments - refunds
129
130 =cut
131
132 sub cashflow {
133   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
134
135     $self->payments($speriod, $eperiod, $agentnum, %opt)
136   - $self->refunds( $speriod, $eperiod, $agentnum, %opt);
137 }
138
139 =item netcashflow: payments - netrefunds
140
141 =cut
142
143 sub netcashflow {
144   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
145
146     $self->receipts(   $speriod, $eperiod, $agentnum, %opt)
147   - $self->netrefunds( $speriod, $eperiod, $agentnum, %opt);
148 }
149
150 =item payments: The sum of payments received in the period.
151
152 =cut
153
154 sub payments {
155   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
156   $self->scalar_sql("
157     SELECT SUM(paid)
158       FROM cust_pay
159         LEFT JOIN cust_main USING ( custnum )
160       WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum).
161                $self->for_opts(%opt)
162   );
163 }
164
165 =item credits: The sum of credits issued in the period.
166
167 =cut
168
169 sub credits {
170   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
171   $self->scalar_sql("
172     SELECT SUM(cust_credit.amount)
173       FROM cust_credit
174         LEFT JOIN cust_main USING ( custnum )
175       WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum).
176                $self->for_opts(%opt)
177   );
178 }
179
180 =item refunds: The sum of refunds paid in the period.
181
182 =cut
183
184 sub refunds {
185   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
186   $self->scalar_sql("
187     SELECT SUM(refund)
188       FROM cust_refund
189         LEFT JOIN cust_main USING ( custnum )
190       WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum).
191                $self->for_opts(%opt)
192   );
193 }
194
195 =item netcredits: The sum of credit applications to invoices in the period.
196
197 =cut
198
199 sub netcredits {
200   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
201
202   my $sql = 'SELECT SUM(cust_credit_bill.amount) FROM cust_credit_bill';
203   if ( $opt{'setuprecur'} ) {
204     $sql = 'SELECT SUM('.
205             FS::cust_bill_pkg->credited_sql($speriod, $eperiod, %opt).
206            ') FROM cust_bill_pkg';
207   }
208
209   $self->scalar_sql("
210     $sql
211         LEFT JOIN cust_bill USING ( invnum  )
212         LEFT JOIN cust_main USING ( custnum )
213       WHERE ". $self->in_time_period_and_agent( $speriod,
214                                                 $eperiod,
215                                                 $agentnum,
216                                                 'cust_bill._date'
217                                               ).
218                $self->for_opts(%opt)
219   );
220 }
221
222 =item receipts: The sum of payment applications to invoices in the period.
223
224 =cut
225
226 sub receipts { #net payments
227   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
228
229   my $sql = 'SELECT SUM(cust_bill_pay.amount) FROM cust_bill_pay';
230   if ( $opt{'setuprecur'} ) {
231     $sql = 'SELECT SUM('.
232             FS::cust_bill_pkg->paid_sql($speriod, $eperiod, %opt).
233            ') FROM cust_bill_pkg';
234   }
235
236   $self->scalar_sql("
237     $sql
238         LEFT JOIN cust_bill USING ( invnum  )
239         LEFT JOIN cust_main USING ( custnum )
240       WHERE ". $self->in_time_period_and_agent( $speriod,
241                                                 $eperiod,
242                                                 $agentnum,
243                                                 'cust_bill._date'
244                                               ).
245                $self->for_opts(%opt)
246   );
247 }
248
249 =item netrefunds: The sum of refund applications to credits in the period.
250
251 =cut
252
253 sub netrefunds {
254   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
255   $self->scalar_sql("
256     SELECT SUM(cust_credit_refund.amount)
257       FROM cust_credit_refund
258         LEFT JOIN cust_credit USING ( crednum  )
259         LEFT JOIN cust_main   USING ( custnum )
260       WHERE ". $self->in_time_period_and_agent( $speriod,
261                                                 $eperiod,
262                                                 $agentnum,
263                                                 'cust_credit._date'
264                                               ).
265                $self->for_opts(%opt)
266   );
267 }
268
269 =item discounted: The sum of discounts on invoices in the period.
270
271 =cut
272
273 sub discounted {
274   my( $self, $speriod, $eperiod, $agentnum, %opt) = @_;
275   $self->scalar_sql('SELECT SUM(cust_bill_pkg_discount.amount)
276     FROM cust_bill_pkg_discount
277       JOIN cust_bill_pkg USING  ( billpkgnum )
278       JOIN cust_bill     USING  ( invnum )
279       JOIN cust_main     USING  ( custnum )
280     WHERE '. $self->in_time_period_and_agent( $speriod,
281                                               $eperiod,
282                                               $agentnum,
283                                               'cust_bill._date'
284                                             ).
285               $self->for_opts(%opt)
286   );
287 }
288
289 =item gross: invoiced + discounted
290
291 =cut
292
293 sub gross {
294   my( $self, $speriod, $eperiod, $agentnum, %opt) = @_;
295     $self->invoiced(   $speriod, $eperiod, $agentnum, %opt)
296   + $self->discounted( $speriod, $eperiod, $agentnum, %opt);
297 }
298
299 #XXX docs
300
301 #these should be auto-generated or $AUTOLOADed or something
302 sub invoiced_12mo {
303   my( $self, $speriod, $eperiod, $agentnum ) = @_;
304   $speriod = $self->_subtract_11mo($speriod);
305   $self->invoiced($speriod, $eperiod, $agentnum);
306 }
307
308 sub netsales_12mo {
309   my( $self, $speriod, $eperiod, $agentnum ) = @_;
310   $speriod = $self->_subtract_11mo($speriod);
311   $self->netsales($speriod, $eperiod, $agentnum);
312 }
313
314 sub receipts_12mo {
315   my( $self, $speriod, $eperiod, $agentnum ) = @_;
316   $speriod = $self->_subtract_11mo($speriod);
317   $self->receipts($speriod, $eperiod, $agentnum);
318 }
319
320 sub payments_12mo {
321   my( $self, $speriod, $eperiod, $agentnum ) = @_;
322   $speriod = $self->_subtract_11mo($speriod);
323   $self->payments($speriod, $eperiod, $agentnum);
324 }
325
326 sub credits_12mo {
327   my( $self, $speriod, $eperiod, $agentnum ) = @_;
328   $speriod = $self->_subtract_11mo($speriod);
329   $self->credits($speriod, $eperiod, $agentnum);
330 }
331
332 sub netcredits_12mo {
333   my( $self, $speriod, $eperiod, $agentnum ) = @_;
334   $speriod = $self->_subtract_11mo($speriod);
335   $self->netcredits($speriod, $eperiod, $agentnum);
336 }
337
338 sub cashflow_12mo {
339   my( $self, $speriod, $eperiod, $agentnum ) = @_;
340   $speriod = $self->_subtract_11mo($speriod);
341   $self->cashflow($speriod, $eperiod, $agentnum);
342 }
343
344 sub netcashflow_12mo {
345   my( $self, $speriod, $eperiod, $agentnum ) = @_;
346   $speriod = $self->_subtract_11mo($speriod);
347   $self->cashflow($speriod, $eperiod, $agentnum);
348 }
349
350 sub refunds_12mo {
351   my( $self, $speriod, $eperiod, $agentnum ) = @_;
352   $speriod = $self->_subtract_11mo($speriod);
353   $self->refunds($speriod, $eperiod, $agentnum);
354 }
355
356 sub netrefunds_12mo {
357   my( $self, $speriod, $eperiod, $agentnum ) = @_;
358   $speriod = $self->_subtract_11mo($speriod);
359   $self->netrefunds($speriod, $eperiod, $agentnum);
360 }
361
362
363 #not being too bad with the false laziness
364 sub _subtract_11mo {
365   my($self, $time) = @_;
366   my ($sec,$min,$hour,$mday,$mon,$year) = (localtime($time) )[0,1,2,3,4,5];
367   $mon -= 11;
368   if ( $mon < 0 ) { $mon+=12; $year--; }
369   timelocal($sec,$min,$hour,$mday,$mon,$year);
370 }
371
372 =item cust_pkg_setup_cost: The total setup costs of packages setup in the period
373
374 'classnum': limit to this package class.
375
376 =cut
377
378 sub cust_pkg_setup_cost {
379   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
380   my $where = '';
381
382   if ( $opt{'classnum'} ne '' ) {
383     my $classnums = $opt{'classnum'};
384     $classnums = [ $classnums ] if !ref($classnums);
385     @$classnums = grep /^\d+$/, @$classnums;
386     $where .= ' AND COALESCE(part_pkg.classnum,0) IN ('. join(',', @$classnums).
387                                                     ')';
388   }
389
390   $agentnum ||= $opt{'agentnum'};
391
392   my $total_sql = " SELECT SUM(part_pkg.setup_cost) ";
393   $total_sql .= " FROM cust_pkg 
394              LEFT JOIN cust_main USING ( custnum )
395              LEFT JOIN part_pkg  USING ( pkgpart )
396                   WHERE pkgnum != 0
397                   $where
398                   AND ".$self->in_time_period_and_agent(
399                     $speriod, $eperiod, $agentnum, 'cust_pkg.setup');
400   return $self->scalar_sql($total_sql);
401 }
402
403 =item cust_pkg_recur_cust: the total recur costs of packages in the period
404
405 'classnum': limit to this package class.
406
407 =cut
408
409 sub cust_pkg_recur_cost {
410   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
411   my $where = '';
412
413   if ( $opt{'classnum'} ne '' ) {
414     my $classnums = $opt{'classnum'};
415     $classnums = [ $classnums ] if !ref($classnums);
416     @$classnums = grep /^\d+$/, @$classnums;
417     $where .= ' AND COALESCE(part_pkg.classnum,0) IN ('. join(',', @$classnums).
418                                                     ')';
419   }
420
421   $agentnum ||= $opt{'agentnum'};
422   # duplication of in_time_period_and_agent
423   # because we do it a little differently here
424   $where .= " AND cust_main.agentnum = $agentnum" if $agentnum;
425   $where .= " AND ".
426           $FS::CurrentUser::CurrentUser->agentnums_sql('table' => 'cust_main');
427
428   my $total_sql = " SELECT SUM(part_pkg.recur_cost) ";
429   $total_sql .= " FROM cust_pkg
430              LEFT JOIN cust_main USING ( custnum )
431              LEFT JOIN part_pkg  USING ( pkgpart )
432                   WHERE pkgnum != 0
433                   $where
434                   AND cust_pkg.setup < $eperiod
435                   AND (cust_pkg.cancel > $speriod OR cust_pkg.cancel IS NULL)
436                   ";
437   return $self->scalar_sql($total_sql);
438 }
439
440 =item cust_bill_pkg: the total package charges on invoice line items.
441
442 'charges': limit the type of charges included (setup, recur, usage).
443 Should be a string containing one or more of 'S', 'R', or 'U'; if 
444 unspecified, defaults to all three.
445
446 'classnum': limit to this package class.
447
448 'use_override': for line items generated by an add-on package, use the class
449 of the add-on rather than the base package.
450
451 'average_per_cust_pkg': divide the result by the number of distinct packages.
452
453 'distribute': for non-monthly recurring charges, ignore the invoice 
454 date.  Instead, consider the line item's starting/ending dates.  Determine 
455 the fraction of the line item duration that falls within the specified 
456 interval and return that fraction of the recurring charges.  This is 
457 somewhat experimental.
458
459 'project': enable if this is a projected period.  This is very experimental.
460
461 =cut
462
463 sub cust_bill_pkg {
464   my $self = shift;
465   my( $speriod, $eperiod, $agentnum, %opt ) = @_;
466
467   my %charges = map {$_=>1} split('', $opt{'charges'} || 'SRU');
468
469   my $sum = 0;
470   $sum += $self->cust_bill_pkg_setup(@_) if $charges{S};
471   $sum += $self->cust_bill_pkg_recur(@_) if $charges{R};
472   $sum += $self->cust_bill_pkg_detail(@_) if $charges{U};
473
474   if ($opt{'average_per_cust_pkg'}) {
475     my $count = $self->cust_bill_pkg_count_pkgnum(@_);
476     return '' if $count == 0;
477     $sum = sprintf('%.2f', $sum / $count);
478   }
479   $sum;
480 }
481
482 my $cust_bill_pkg_join = '
483     LEFT JOIN cust_bill USING ( invnum )
484     LEFT JOIN cust_main USING ( custnum )
485     LEFT JOIN cust_pkg USING ( pkgnum )
486     LEFT JOIN part_pkg USING ( pkgpart )
487     LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart
488     LEFT JOIN part_fee USING ( feepart )';
489
490 sub cust_bill_pkg_setup {
491   my $self = shift;
492   my ($speriod, $eperiod, $agentnum, %opt) = @_;
493   # no projecting setup fees--use real invoices only
494   # but evaluate this anyway, because the design of projection is that
495   # if there are somehow real setup fees in the future, we want to count
496   # them
497
498   $agentnum ||= $opt{'agentnum'};
499
500   my @where = (
501     '(pkgnum != 0 OR feepart IS NOT NULL)',
502     $self->with_classnum($opt{'classnum'}, $opt{'use_override'}),
503     $self->with_report_option(%opt),
504     $self->in_time_period_and_agent($speriod, $eperiod, $agentnum),
505     $self->with_refnum(%opt),
506     $self->with_cust_classnum(%opt)
507   );
508
509   my $total_sql = "SELECT COALESCE(SUM(cust_bill_pkg.setup),0)
510   FROM cust_bill_pkg
511   $cust_bill_pkg_join
512   WHERE " . join(' AND ', grep $_, @where);
513
514   $self->scalar_sql($total_sql);
515 }
516
517 sub _cust_bill_pkg_recurring {
518   # returns the FROM/WHERE part of the statement to query all recurring 
519   # line items in the period
520   my $self = shift;
521   my ($speriod, $eperiod, $agentnum, %opt) = @_;
522
523   $agentnum ||= $opt{'agentnum'};
524   my $cust_bill_pkg = $opt{'project'} ? 'v_cust_bill_pkg' : 'cust_bill_pkg';
525
526   my @where = (
527     '(pkgnum != 0 OR feepart IS NOT NULL)',
528     $self->with_report_option(%opt),
529     $self->with_refnum(%opt),
530     $self->with_cust_classnum(%opt)
531   );
532
533   my $where_classnum = $self->with_classnum($opt{'classnum'}, $opt{'use_override'});
534   if ($opt{'project'}) {
535     $where_classnum =~ s/\bcust_bill_pkg/v_cust_bill_pkg/g;
536   }
537   push @where, $where_classnum;
538
539   if ( $opt{'distribute'} ) {
540     $where[0] = 'pkgnum != 0'; # specifically exclude fees
541     push @where, "cust_main.agentnum = $agentnum" if $agentnum;
542     push @where,
543       "$cust_bill_pkg.sdate <  $eperiod",
544       "$cust_bill_pkg.edate >= $speriod",
545     ;
546   }
547   else {
548     # we don't want to have to create v_cust_bill
549     my $_date = $opt{'project'} ? 'v_cust_bill_pkg._date' : 'cust_bill._date';
550     push @where, 
551       $self->in_time_period_and_agent($speriod, $eperiod, $agentnum, $_date);
552   }
553
554   return "
555   FROM $cust_bill_pkg 
556   $cust_bill_pkg_join
557   WHERE ".join(' AND ', grep $_, @where);
558
559 }
560
561 sub cust_bill_pkg_recur {
562   my $self = shift;
563   my ($speriod, $eperiod, $agentnum, %opt) = @_;
564
565   # subtract all usage from the line item regardless of date
566   my $item_usage;
567   if ( $opt{'project'} ) {
568     $item_usage = 'usage'; #already calculated
569   }
570   else {
571     $item_usage = '( SELECT COALESCE(SUM(cust_bill_pkg_detail.amount),0)
572       FROM cust_bill_pkg_detail
573       WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum )';
574   }
575   
576   my $cust_bill_pkg = $opt{'project'} ? 'v_cust_bill_pkg' : 'cust_bill_pkg';
577
578   my $recur_fraction = '';
579   if ($opt{'distribute'}) {
580     # the fraction of edate - sdate that's within [speriod, eperiod]
581     $recur_fraction = " * 
582       CAST(LEAST($eperiod, $cust_bill_pkg.edate) - 
583        GREATEST($speriod, $cust_bill_pkg.sdate) AS DECIMAL) / 
584       ($cust_bill_pkg.edate - $cust_bill_pkg.sdate)";
585   }
586
587   my $total_sql = 
588     "SELECT COALESCE(SUM(($cust_bill_pkg.recur - $item_usage) $recur_fraction),0)" .
589     $self->_cust_bill_pkg_recurring(@_);
590
591   $self->scalar_sql($total_sql);
592 }
593
594 sub cust_bill_pkg_count_pkgnum {
595   # for ARPU calculation
596   my $self = shift;
597   my $total_sql = 'SELECT COUNT(DISTINCT pkgnum) '.
598     $self->_cust_bill_pkg_recurring(@_);
599
600   $self->scalar_sql($total_sql);
601 }
602
603 =item cust_bill_pkg_detail: the total usage charges in detail lines.
604
605 Arguments as for C<cust_bill_pkg>, plus:
606
607 'usageclass': limit to this usage class number.
608
609 =cut
610
611 sub cust_bill_pkg_detail {
612   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
613
614   my @where = 
615     ( "(cust_bill_pkg.pkgnum != 0 OR cust_bill_pkg.feepart IS NOT NULL)" );
616
617   $agentnum ||= $opt{'agentnum'};
618
619   push @where,
620     $self->with_classnum($opt{'classnum'}, $opt{'use_override'}),
621     $self->with_usageclass($opt{'usageclass'}),
622     $self->with_report_option(%opt),
623     $self->with_refnum(%opt),
624     $self->with_cust_classnum(%opt)
625     ;
626
627   if ( $opt{'distribute'} ) {
628     # exclude fees
629     $where[0] = 'cust_bill_pkg.pkgnum != 0';
630     # and limit according to the usage time, not the billing date
631     push @where, $self->in_time_period_and_agent($speriod, $eperiod, $agentnum,
632       'cust_bill_pkg_detail.startdate'
633     );
634   }
635   else {
636     push @where, $self->in_time_period_and_agent($speriod, $eperiod, $agentnum,
637       'cust_bill._date'
638     );
639   }
640
641   my $total_sql = " SELECT SUM(cust_bill_pkg_detail.amount) ";
642
643   $total_sql .=
644     " FROM cust_bill_pkg_detail
645         LEFT JOIN cust_bill_pkg USING ( billpkgnum )
646         LEFT JOIN cust_bill ON cust_bill_pkg.invnum = cust_bill.invnum
647         LEFT JOIN cust_main USING ( custnum )
648         LEFT JOIN cust_pkg ON cust_bill_pkg.pkgnum = cust_pkg.pkgnum
649         LEFT JOIN part_pkg USING ( pkgpart )
650         LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart
651         LEFT JOIN part_fee USING ( feepart )
652       WHERE ".join( ' AND ', grep $_, @where );
653
654   $self->scalar_sql($total_sql);
655   
656 }
657
658 sub cust_bill_pkg_discount {
659   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
660
661   #need to do this the new multi-classnum way if it gets re-enabled
662   #my $where = '';
663   #my $comparison = '';
664   #if ( $opt{'classnum'} =~ /^(\d+)$/ ) {
665   #  if ( $1 == 0 ) {
666   #    $comparison = "IS NULL";
667   #  } else {
668   #    $comparison = "= $1";
669   #  }
670   #
671   #  if ( $opt{'use_override'} ) {
672   #    $where = "(
673   #      part_pkg.classnum $comparison AND pkgpart_override IS NULL OR
674   #      override.classnum $comparison AND pkgpart_override IS NOT NULL
675   #    )";
676   #  } else {
677   #    $where = "part_pkg.classnum $comparison";
678   #  }
679   #}
680
681   $agentnum ||= $opt{'agentnum'};
682
683   my $total_sql =
684     " SELECT COALESCE( SUM( cust_bill_pkg_discount.amount ), 0 ) ";
685
686   $total_sql .=
687     " FROM cust_bill_pkg_discount
688         LEFT JOIN cust_bill_pkg USING ( billpkgnum )
689         LEFT JOIN cust_bill USING ( invnum )
690         LEFT JOIN cust_main USING ( custnum )
691       WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum);
692   #      LEFT JOIN cust_pkg_discount USING ( pkgdiscountnum )
693   #      LEFT JOIN discount USING ( discountnum )
694   #      LEFT JOIN cust_pkg USING ( pkgnum )
695   #      LEFT JOIN part_pkg USING ( pkgpart )
696   #      LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart
697   
698   return $self->scalar_sql($total_sql);
699
700 }
701
702 ##### package churn report #####
703
704 =item active_pkg: The number of packages that were active at the start of 
705 the period. The end date of the period is ignored. Options:
706
707 - refnum: Limit to customers with this advertising source.
708 - classnum: Limit to packages with this class.
709 - towernum: Limit to packages that have a broadband service with this tower.
710 - zip: Limit to packages with this service location zip code.
711
712 Except for zip, any of these can be an arrayref to allow multiple values for
713 the field.
714
715 =item setup_pkg: The number of packages with setup dates in the period. This 
716 excludes packages created by package changes. Options are as for active_pkg.
717
718 =item susp_pkg: The number of packages that were suspended in the period
719 (and not canceled).  Options are as for active_pkg.
720
721 =item unsusp_pkg: The number of packages that were unsuspended in the period.
722 Options are as for active_pkg.
723
724 =item cancel_pkg: The number of packages with cancel dates in the period.
725 Excludes packages that were canceled to be changed to a new package. Options
726 are as for active_pkg.
727
728 =cut
729
730 sub active_pkg {
731   my $self = shift;
732   $self->churn_pkg('active', @_);
733 }
734
735 sub setup_pkg {
736   my $self = shift;
737   $self->churn_pkg('setup', @_);
738 }
739
740 sub cancel_pkg {
741   my $self = shift;
742   $self->churn_pkg('cancel', @_);
743 }
744
745 sub susp_pkg {
746   my $self = shift;
747   $self->churn_pkg('susp', @_);
748 }
749
750 sub unsusp_pkg {
751   my $self = shift;
752   $self->churn_pkg('unsusp', @_);
753 }
754
755 sub churn_pkg {
756   my $self = shift;
757   my ( $status, $speriod, $eperiod, $agentnum, %opt ) = @_;
758   my ($from, @where) =
759     FS::h_cust_pkg->churn_fromwhere_sql( $status, $speriod, $eperiod);
760
761   push @where, $self->pkg_where(%opt, 'agentnum' => $agentnum);
762
763   my $sql = "SELECT COUNT(*) FROM $from
764     JOIN part_pkg ON (cust_pkg.pkgpart = part_pkg.pkgpart)
765     JOIN cust_main ON (cust_pkg.custnum = cust_main.custnum)";
766   $sql .= ' WHERE '.join(' AND ', @where)
767     if scalar(@where);
768
769   $self->scalar_sql($sql);
770 }
771
772 sub pkg_where {
773   my $self = shift;
774   my %opt = @_;
775   my @where = (
776     "part_pkg.freq != '0'",
777     $self->with_refnum(%opt),
778     $self->with_towernum(%opt),
779     $self->with_zip(%opt),
780   );
781   if ($opt{agentnum} =~ /^(\d+)$/) {
782     push @where, "cust_main.agentnum = $1";
783   }
784   if ($opt{classnum}) {
785     my $classnum = $opt{classnum};
786     $classnum = [ $classnum ] if !ref($classnum);
787     @$classnum = grep /^\d+$/, @$classnum;
788     my $in = 'IN ('. join(',', @$classnum). ')';
789     push @where, "COALESCE(part_pkg.classnum, 0) $in" if scalar @$classnum;
790   }
791   @where;
792 }
793
794 ##### end of package churn report stuff #####
795
796 ##### customer churn report #####
797
798 =item active_cust: The number of customers who had any active recurring 
799 packages at the start of the period. The end date is ignored, agentnum is 
800 mandatory, and no other parameters are accepted.
801
802 =item started_cust: The number of customers who had no active packages at 
803 the start of the period, but had active packages at the end. Like
804 active_cust, agentnum is mandatory and no other parameters are accepted.
805
806 =item suspended_cust: The number of customers who had active packages at
807 the start of the period, and at the end had no active packages but some
808 suspended packages. Note that this does not necessarily mean that their 
809 packages were suspended during the period.
810
811 =item resumed_cust: The inverse of suspended_cust: the number of customers
812 who had suspended packages and no active packages at the start of the 
813 period, and active packages at the end.
814
815 =item cancelled_cust: The number of customers who had active packages
816 at the start of the period, and only cancelled packages at the end.
817
818 =cut
819
820 sub active_cust {
821   my $self = shift;
822   $self->churn_cust(@_)->{active};
823 }
824 sub started_cust {
825   my $self = shift;
826   $self->churn_cust(@_)->{started};
827 }
828 sub suspended_cust {
829   my $self = shift;
830   $self->churn_cust(@_)->{suspended};
831 }
832 sub resumed_cust {
833   my $self = shift;
834   $self->churn_cust(@_)->{resumed};
835 }
836 sub cancelled_cust {
837   my $self = shift;
838   $self->churn_cust(@_)->{cancelled};
839 }
840
841 sub churn_cust {
842   my $self = shift;
843   my ( $speriod ) = @_;
844
845   # run one query for each interval
846   return $self->{_interval}{$speriod} ||= $self->calculate_churn_cust(@_);
847 }
848
849 sub calculate_churn_cust {
850   my $self = shift;
851   my ($speriod, $eperiod, $agentnum, %opt) = @_;
852
853   my $churn_sql = FS::cust_main::Status->churn_sql($speriod, $eperiod);
854   my $where = '';
855   $where = " WHERE cust_main.agentnum = $agentnum " if $agentnum;
856   my $cust_sql =
857     "SELECT churn.* ".
858     "FROM cust_main JOIN ($churn_sql) AS churn USING (custnum)".
859     $where;
860
861   # query to count the ones with certain status combinations
862   my $total_sql = "
863     SELECT SUM((s_active > 0)::int)                   as active,
864            SUM((s_active = 0 and e_active > 0)::int)  as started,
865            SUM((s_active > 0 and e_active = 0 and e_suspended > 0)::int)
866                                                       as suspended,
867            SUM((s_active = 0 and s_suspended > 0 and e_active > 0)::int)
868                                                       as resumed,
869            SUM((s_active > 0 and e_active = 0 and e_suspended = 0)::int)
870                                                       as cancelled
871     FROM ($cust_sql) AS x
872   ";
873
874   my $sth = dbh->prepare($total_sql);
875   $sth->execute or die "failed to execute churn query: " . $sth->errstr;
876
877   $self->{_interval}{$speriod} = $sth->fetchrow_hashref;
878 }
879
880 sub in_time_period_and_agent {
881   my( $self, $speriod, $eperiod, $agentnum ) = splice(@_, 0, 4);
882   my $col = @_ ? shift() : '_date';
883
884   my $sql = "$col >= $speriod AND $col < $eperiod";
885
886   #agent selection
887   $sql .= " AND cust_main.agentnum = $agentnum"
888     if $agentnum;
889
890   #agent virtualization
891   $sql .= ' AND '.
892           $FS::CurrentUser::CurrentUser->agentnums_sql( 'table'=>'cust_main' );
893
894   $sql;
895 }
896
897 sub for_opts {
898     my ( $self, %opt ) = @_;
899     my $sql = '';
900     if ( $opt{'custnum'} =~ /^(\d+)$/ ) {
901       $sql .= " and custnum = $1 ";
902     }
903     if ( $opt{'refnum'} ) {
904       my $refnum = $opt{'refnum'};
905       $refnum = [ $refnum ] if !ref($refnum);
906       my $in = join(',', grep /^\d+$/, @$refnum);
907       $sql .= " and refnum IN ($in)" if length $in;
908     }
909     if ( my $where = $self->with_cust_classnum(%opt) ) {
910       $sql .= " and $where";
911     }
912
913     $sql;
914 }
915
916 sub with_classnum {
917   my ($self, $classnum, $use_override) = @_;
918   return '' if $classnum eq '';
919
920   $classnum = [ $classnum ] if !ref($classnum);
921   @$classnum = grep /^\d+$/, @$classnum;
922   my $in = 'IN ('. join(',', @$classnum). ')';
923
924   if ( $use_override ) {
925     # then include packages if their base package is in the set and they are 
926     # not overridden,
927     # or if they are overridden and their override package is in the set,
928     # or fees if they are in the set
929     return "(
930          ( COALESCE(part_pkg.classnum, 0) $in AND cust_pkg.pkgpart IS NOT NULL AND pkgpart_override IS NULL )
931       OR ( COALESCE(override.classnum, 0) $in AND pkgpart_override IS NOT NULL )
932       OR ( COALESCE(part_fee.classnum, 0) $in AND cust_bill_pkg.feepart IS NOT NULL )
933     )";
934   } else {
935     # include packages if their base package is in the set,
936     # or fees if they are in the set
937     return "(
938          ( COALESCE(part_pkg.classnum, 0) $in AND cust_pkg.pkgpart IS NOT NULL )
939       OR ( COALESCE(part_fee.classnum, 0) $in AND cust_bill_pkg.feepart IS NOT NULL )
940     )";
941   }
942 }
943
944 sub with_usageclass {
945   my $self = shift;
946   my ($classnum, $use_override) = @_;
947   return '' unless $classnum =~ /^\d+$/;
948   my $comparison;
949   if ( $classnum == 0 ) {
950     $comparison = 'IS NULL';
951   }
952   else {
953     $comparison = "= $classnum";
954   }
955   return "cust_bill_pkg_detail.classnum $comparison";
956 }
957
958 sub with_report_option {
959   my ($self, %opt) = @_;
960   # %opt can contain:
961   # - report_optionnum: a comma-separated list of numbers.  Zero means to 
962   #   include packages with _no_ report classes.
963   # - not_report_optionnum: a comma-separated list.  Packages that have 
964   #   any of these report options will be excluded from the result.
965   #   Zero does nothing.
966   # - use_override: also matches line items that are add-ons to a package
967   #   matching the report class.
968   # - all_report_options: returns only packages that have ALL of the
969   #   report classes listed in $num.  Otherwise, will return packages that 
970   #   have ANY of those classes.
971
972   my @num = ref($opt{'report_optionnum'})
973                   ? @{ $opt{'report_optionnum'} }
974                   : split(/\s*,\s*/, $opt{'report_optionnum'});
975   my @not_num = ref($opt{'not_report_optionnum'})
976                       ? @{ $opt{'not_report_optionnum'} }
977                       : split(/\s*,\s*/, $opt{'not_report_optionnum'});
978   my $null;
979   $null = 1 if ( grep {$_ == 0} @num );
980   @num = grep {$_ > 0} @num;
981   @not_num = grep {$_ > 0} @not_num;
982
983   # brute force
984   my $table = $opt{'use_override'} ? 'override' : 'part_pkg';
985   my $op = ' OR ';
986   if ( $opt{'all_report_options'} ) {
987     if ( @num and $null ) {
988       return 'false'; # mutually exclusive criteria, so just bail out
989     }
990     $op = ' AND ';
991   }
992   my @where_num = map {
993     "EXISTS(SELECT 1 FROM part_pkg_option ".
994     "WHERE optionname = 'report_option_$_' ".
995     "AND part_pkg_option.pkgpart = $table.pkgpart)"
996   } @num;
997   if ( $null ) {
998     push @where_num, "NOT EXISTS(SELECT 1 FROM part_pkg_option ".
999                      "WHERE optionname LIKE 'report_option_%' ".
1000                      "AND part_pkg_option.pkgpart = $table.pkgpart)";
1001   }
1002   my @where_not_num = map {
1003     "NOT EXISTS(SELECT 1 FROM part_pkg_option ".
1004     "WHERE optionname = 'report_option_$_' ".
1005     "AND part_pkg_option.pkgpart = $table.pkgpart)"
1006   } @not_num;
1007
1008   my @where;
1009   if (@where_num) {
1010     push @where, '( '.join($op, @where_num).' )';
1011   }
1012   if (@where_not_num) {
1013     push @where, '( '.join(' AND ', @where_not_num).' )';
1014   }
1015
1016   return @where;
1017   # this messes up totals
1018   #if ( $opt{'use_override'} ) {
1019   #  # then also allow the non-override package to match
1020   #  delete $opt{'use_override'};
1021   #  $comparison = "( $comparison OR " . $self->with_report_option(%opt) . ")";
1022   #}
1023
1024 }
1025
1026 sub with_refnum {
1027   my ($self, %opt) = @_;
1028   if ( $opt{'refnum'} ) {
1029     my $refnum = $opt{'refnum'};
1030     $refnum = [ $refnum ] if !ref($refnum);
1031     my $in = join(',', grep /^\d+$/, @$refnum);
1032     return "cust_main.refnum IN ($in)" if length $in;
1033   }
1034   return;
1035 }
1036
1037 sub with_towernum {
1038   my ($self, %opt) = @_;
1039   if ( $opt{'towernum'} ) {
1040     my $towernum = $opt{'towernum'};
1041     $towernum = [ $towernum ] if !ref($towernum);
1042     my $in = join(',', grep /^\d+$/, @$towernum);
1043     return unless length($in); # if no towers are specified, don't restrict
1044
1045     # materialize/cache the set of pkgnums that, as of the last
1046     # svc_broadband history record, had a certain towernum
1047     # (because otherwise this is painfully slow)
1048     $self->_init_tower_pkg_cache;
1049
1050     return "EXISTS(
1051             SELECT 1 FROM tower_pkg_cache
1052               WHERE towernum IN($in)
1053               AND cust_pkg.pkgnum = tower_pkg_cache.pkgnum
1054             )";
1055   }
1056   return;
1057 }
1058
1059 sub with_zip {
1060   my ($self, %opt) = @_;
1061   if (length($opt{'zip'})) {
1062     return "(SELECT zip FROM cust_location 
1063              WHERE cust_location.locationnum = cust_pkg.locationnum
1064             ) = " . dbh->quote($opt{'zip'});
1065   }
1066   return;
1067 }
1068
1069 sub with_cust_classnum {
1070   my ($self, %opt) = @_;
1071   if ( $opt{'cust_classnum'} ) {
1072     my $classnums = $opt{'cust_classnum'};
1073     $classnums = [ $classnums ] if !ref($classnums);
1074     @$classnums = grep /^\d+$/, @$classnums;
1075     return 'cust_main.classnum in('. join(',',@$classnums) .')'
1076       if @$classnums;
1077   }
1078   return; 
1079 }
1080
1081
1082 sub scalar_sql {
1083   my( $self, $sql ) = ( shift, shift );
1084   my $sth = dbh->prepare($sql) or die dbh->errstr;
1085   warn "FS::Report::Table\n$sql\n" if $DEBUG;
1086   $sth->execute
1087     or die "Unexpected error executing statement $sql: ". $sth->errstr;
1088   $sth->fetchrow_arrayref->[0] || 0;
1089 }
1090
1091 =back
1092
1093 =head1 METHODS
1094
1095 =over 4
1096
1097 =item init_projection
1098
1099 Sets up for future projection of all observables on the report.  Currently 
1100 this is limited to 'cust_bill_pkg'.
1101
1102 =cut
1103
1104 sub init_projection {
1105   # this is weird special case stuff--some redesign may be needed 
1106   # to use it for anything else
1107   my $self = shift;
1108
1109   if ( driver_name ne 'Pg' ) {
1110     # also database-specific for now
1111     die "projection reports not supported on this platform";
1112   }
1113
1114   my %items = map {$_ => 1} @{ $self->{items} };
1115   if ($items{'cust_bill_pkg'}) {
1116     my $dbh = dbh;
1117     # v_ for 'virtual'
1118     my @sql = (
1119       # could use TEMPORARY TABLE but we're already transaction-protected
1120       'DROP TABLE IF EXISTS v_cust_bill_pkg',
1121       'CREATE TABLE v_cust_bill_pkg ' . 
1122        '(LIKE cust_bill_pkg,
1123           usage numeric(10,2), _date integer, expire integer)',
1124       # XXX this should be smart enough to take only the ones with 
1125       # sdate/edate overlapping the ROI, for performance
1126       "INSERT INTO v_cust_bill_pkg ( 
1127         SELECT cust_bill_pkg.*,
1128           (SELECT COALESCE(SUM(cust_bill_pkg_detail.amount),0)
1129           FROM cust_bill_pkg_detail 
1130           WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum),
1131           cust_bill._date,
1132           cust_pkg.expire
1133         FROM cust_bill_pkg $cust_bill_pkg_join
1134       )",
1135     );
1136     foreach my $sql (@sql) {
1137       warn "[init_projection] $sql\n" if $DEBUG;
1138       $dbh->do($sql) or die $dbh->errstr;
1139     }
1140   }
1141 }
1142
1143 =item extend_projection START END
1144
1145 Generates data for the next period of projection.  This will be called 
1146 for sequential periods where the END of one equals the START of the next
1147 (with no gaps).
1148
1149 =cut
1150
1151 sub extend_projection {
1152   my $self = shift;
1153   my ($speriod, $eperiod) = @_;
1154   my %items = map {$_ => 1} @{ $self->{items} };
1155   if ($items{'cust_bill_pkg'}) {
1156     # What we do here:
1157     # Find all line items that end after the start of the period (and have 
1158     # recurring fees, and don't expire before they end).  Choose the latest 
1159     # one for each package.  If it ends before the end of the period, copy
1160     # it forward by one billing period.
1161     # Repeat this until the latest line item for each package no longer ends
1162     # within the period.  This is certain to happen in finitely many 
1163     # iterations as long as freq > 0.
1164     # - Pg only, obviously.
1165     # - Gives bad results if freq_override is used.
1166     my @fields = ( FS::cust_bill_pkg->fields, qw( usage _date expire ) );
1167     my $insert_fields = join(',', @fields);
1168     my $add_freq = sub { # emulate FS::part_pkg::add_freq
1169       my $field = shift;
1170       "EXTRACT( EPOCH FROM TO_TIMESTAMP($field) + (CASE WHEN freq ~ E'\\\\D' ".
1171       "THEN freq ELSE freq || 'mon' END)::INTERVAL) AS $field";
1172     };
1173     foreach (@fields) {
1174       if ($_ eq 'edate') {
1175         $_ = $add_freq->('edate');
1176       }
1177       elsif ($_ eq 'sdate') {
1178         $_ = 'edate AS sdate'
1179       }
1180       elsif ($_ eq 'setup') {
1181         $_ = '0 AS setup' #because recurring only
1182       }
1183       elsif ($_ eq '_date') {
1184         $_ = $add_freq->('_date');
1185       }
1186     }
1187     my $select_fields = join(',', @fields);
1188     my $dbh = dbh;
1189     my $sql =
1190     # Subquery here because we need to DISTINCT the whole set, select the 
1191     # latest charge per pkgnum, and _then_ check edate < $eperiod 
1192     # and edate < expire.
1193       "INSERT INTO v_cust_bill_pkg ($insert_fields)
1194         SELECT $select_fields FROM (
1195           SELECT DISTINCT ON (pkgnum) * FROM v_cust_bill_pkg
1196             WHERE edate >= $speriod 
1197               AND recur > 0
1198               AND freq IS NOT NULL
1199               AND freq != '0'
1200             ORDER BY pkgnum, edate DESC
1201           ) AS v1 
1202           WHERE edate < $eperiod AND (edate < expire OR expire IS NULL)";
1203     my $rows;
1204     do {
1205       warn "[extend_projection] $sql\n" if $DEBUG;
1206       $rows = $dbh->do($sql) or die $dbh->errstr;
1207       warn "[extend_projection] $rows rows\n" if $DEBUG;
1208     } until $rows == 0;
1209   }
1210 }
1211
1212 =item _init_tower_pkg_cache
1213
1214 Internal method: creates a temporary table relating pkgnums to towernums.
1215 A (pkgnum, towernum) record indicates that this package once had a 
1216 svc_broadband service which, as of its last insert or replace_new history 
1217 record, had a sectornum associated with that towernum.
1218
1219 This is expensive, so it won't be done more than once an hour. Historical 
1220 data about package churn shouldn't be changing in realtime anyway.
1221
1222 =cut
1223
1224 sub _init_tower_pkg_cache {
1225   my $self = shift;
1226   my $dbh = dbh;
1227
1228   my $current = $CACHE->get('tower_pkg_cache_update');
1229   return if $current;
1230  
1231   # XXX or should this be in the schema?
1232   my $sql = "DROP TABLE IF EXISTS tower_pkg_cache";
1233   $dbh->do($sql) or die $dbh->errstr;
1234   $sql = "CREATE TABLE tower_pkg_cache (towernum int, pkgnum int)";
1235   $dbh->do($sql) or die $dbh->errstr;
1236
1237   # assumptions:
1238   # sectornums never get reused, or move from one tower to another
1239   # all service history is intact
1240   # svcnums never get reused (this would be bad)
1241   # pkgnums NEVER get reused (this would be extremely bad)
1242   $sql = "INSERT INTO tower_pkg_cache (
1243     SELECT COALESCE(towernum,0), pkgnum
1244     FROM ( SELECT DISTINCT pkgnum, svcnum FROM h_cust_svc ) AS pkgnum_svcnum
1245     LEFT JOIN (
1246       SELECT DISTINCT ON(svcnum) svcnum, sectornum
1247         FROM h_svc_broadband
1248         WHERE (history_action = 'replace_new'
1249                OR history_action = 'replace_old')
1250         ORDER BY svcnum ASC, history_date DESC
1251     ) AS svcnum_sectornum USING (svcnum)
1252     LEFT JOIN tower_sector USING (sectornum)
1253   )";
1254   $dbh->do($sql) or die $dbh->errstr;
1255
1256   $CACHE->set('tower_pkg_cache_update', 1, 3600);
1257
1258 };
1259
1260 =head1 BUGS
1261
1262 Documentation.
1263
1264 =head1 SEE ALSO
1265
1266 L<FS::Report::Table::Monthly>, reports in the web interface.
1267
1268 =cut
1269
1270 1;