projected sales report, #15393
[freeside.git] / FS / FS / Report / Table.pm
1 package FS::Report::Table;
2
3 use strict;
4 use vars qw( @ISA $DEBUG );
5 use FS::Report;
6 use Time::Local qw( timelocal );
7 use FS::UID qw( dbh driver_name );
8 use FS::Report::Table;
9 use FS::CurrentUser;
10
11 $DEBUG = 0; # turning this on will trace all SQL statements, VERY noisy
12 @ISA = qw( FS::Report );
13
14 =head1 NAME
15
16 FS::Report::Table - Tables of report data
17
18 =head1 SYNOPSIS
19
20 See the more specific report objects, currently only 
21 FS::Report::Table::Monthly and FS::Report::Table::Daily.
22
23 =head1 OBSERVABLES
24
25 The common interface for an observable named 'foo' is:
26
27 $report->foo($startdate, $enddate, $agentnum, %options)
28
29 This returns a scalar value for foo, over the period from 
30 $startdate to $enddate, limited to agent $agentnum, subject to 
31 options in %opt.
32
33 =over 4
34
35 =item invoiced: The total amount charged on all invoices.
36
37 =cut
38
39 sub invoiced { #invoiced
40   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
41
42   $self->scalar_sql("
43     SELECT SUM(charged)
44       FROM cust_bill
45         LEFT JOIN cust_main USING ( custnum )
46       WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum)
47       . (%opt ? $self->for_custnum(%opt) : '')
48   );
49   
50 }
51
52 =item netsales: invoiced - netcredits
53
54 =cut
55
56 sub netsales { #net sales
57   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
58
59     $self->invoiced($speriod,$eperiod,$agentnum,%opt)
60   - $self->netcredits($speriod,$eperiod,$agentnum,%opt);
61 }
62
63 =item cashflow: payments - refunds
64
65 =cut
66
67 sub cashflow {
68   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
69
70     $self->payments($speriod, $eperiod, $agentnum, %opt)
71   - $self->refunds( $speriod, $eperiod, $agentnum, %opt);
72 }
73
74 =item netcashflow: payments - netrefunds
75
76 =cut
77
78 sub netcashflow {
79   my( $self, $speriod, $eperiod, $agentnum ) = @_;
80
81     $self->receipts($speriod, $eperiod, $agentnum)
82   - $self->netrefunds( $speriod, $eperiod, $agentnum);
83 }
84
85 =item payments: The sum of payments received in the period.
86
87 =cut
88
89 sub payments {
90   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
91   $self->scalar_sql("
92     SELECT SUM(paid)
93       FROM cust_pay
94         LEFT JOIN cust_main USING ( custnum )
95       WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum)
96       . (%opt ? $self->for_custnum(%opt) : '')
97   );
98 }
99
100 =item credits: The sum of credits issued in the period.
101
102 =cut
103
104 sub credits {
105   my( $self, $speriod, $eperiod, $agentnum ) = @_;
106   $self->scalar_sql("
107     SELECT SUM(amount)
108       FROM cust_credit
109         LEFT JOIN cust_main USING ( custnum )
110       WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum)
111   );
112 }
113
114 =item refunds: The sum of refunds paid in the period.
115
116 =cut
117
118 sub refunds {
119   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
120   $self->scalar_sql("
121     SELECT SUM(refund)
122       FROM cust_refund
123         LEFT JOIN cust_main USING ( custnum )
124       WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum)
125       . (%opt ? $self->for_custnum(%opt) : '')
126   );
127 }
128
129 =item netcredits: The sum of credit applications to invoices in the period.
130
131 =cut
132
133 sub netcredits {
134   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
135   $self->scalar_sql("
136     SELECT SUM(cust_credit_bill.amount)
137       FROM cust_credit_bill
138         LEFT JOIN cust_bill USING ( invnum  )
139         LEFT JOIN cust_main USING ( custnum )
140       WHERE ". $self->in_time_period_and_agent( $speriod,
141                                                 $eperiod,
142                                                 $agentnum,
143                                                 'cust_bill._date'
144                                               )
145       . (%opt ? $self->for_custnum(%opt) : '')
146   );
147 }
148
149 =item receipts: The sum of payment applications to invoices in the period.
150
151 =cut
152
153 sub receipts { #net payments
154   my( $self, $speriod, $eperiod, $agentnum ) = @_;
155   $self->scalar_sql("
156     SELECT SUM(cust_bill_pay.amount)
157       FROM cust_bill_pay
158         LEFT JOIN cust_bill USING ( invnum  )
159         LEFT JOIN cust_main USING ( custnum )
160       WHERE ". $self->in_time_period_and_agent( $speriod,
161                                                 $eperiod,
162                                                 $agentnum,
163                                                 'cust_bill._date'
164                                               )
165   );
166 }
167
168 =item netrefunds: The sum of refund applications to credits in the period.
169
170 =cut
171
172 sub netrefunds {
173   my( $self, $speriod, $eperiod, $agentnum ) = @_;
174   $self->scalar_sql("
175     SELECT SUM(cust_credit_refund.amount)
176       FROM cust_credit_refund
177         LEFT JOIN cust_credit USING ( crednum  )
178         LEFT JOIN cust_main   USING ( custnum )
179       WHERE ". $self->in_time_period_and_agent( $speriod,
180                                                 $eperiod,
181                                                 $agentnum,
182                                                 'cust_credit._date'
183                                               )
184   );
185 }
186
187 #XXX docs
188
189 #these should be auto-generated or $AUTOLOADed or something
190 sub invoiced_12mo {
191   my( $self, $speriod, $eperiod, $agentnum ) = @_;
192   $speriod = $self->_subtract_11mo($speriod);
193   $self->invoiced($speriod, $eperiod, $agentnum);
194 }
195
196 sub netsales_12mo {
197   my( $self, $speriod, $eperiod, $agentnum ) = @_;
198   $speriod = $self->_subtract_11mo($speriod);
199   $self->netsales($speriod, $eperiod, $agentnum);
200 }
201
202 sub receipts_12mo {
203   my( $self, $speriod, $eperiod, $agentnum ) = @_;
204   $speriod = $self->_subtract_11mo($speriod);
205   $self->receipts($speriod, $eperiod, $agentnum);
206 }
207
208 sub payments_12mo {
209   my( $self, $speriod, $eperiod, $agentnum ) = @_;
210   $speriod = $self->_subtract_11mo($speriod);
211   $self->payments($speriod, $eperiod, $agentnum);
212 }
213
214 sub credits_12mo {
215   my( $self, $speriod, $eperiod, $agentnum ) = @_;
216   $speriod = $self->_subtract_11mo($speriod);
217   $self->credits($speriod, $eperiod, $agentnum);
218 }
219
220 sub netcredits_12mo {
221   my( $self, $speriod, $eperiod, $agentnum ) = @_;
222   $speriod = $self->_subtract_11mo($speriod);
223   $self->netcredits($speriod, $eperiod, $agentnum);
224 }
225
226 sub cashflow_12mo {
227   my( $self, $speriod, $eperiod, $agentnum ) = @_;
228   $speriod = $self->_subtract_11mo($speriod);
229   $self->cashflow($speriod, $eperiod, $agentnum);
230 }
231
232 sub netcashflow_12mo {
233   my( $self, $speriod, $eperiod, $agentnum ) = @_;
234   $speriod = $self->_subtract_11mo($speriod);
235   $self->cashflow($speriod, $eperiod, $agentnum);
236 }
237
238 sub refunds_12mo {
239   my( $self, $speriod, $eperiod, $agentnum ) = @_;
240   $speriod = $self->_subtract_11mo($speriod);
241   $self->refunds($speriod, $eperiod, $agentnum);
242 }
243
244 sub netrefunds_12mo {
245   my( $self, $speriod, $eperiod, $agentnum ) = @_;
246   $speriod = $self->_subtract_11mo($speriod);
247   $self->netrefunds($speriod, $eperiod, $agentnum);
248 }
249
250
251 #not being too bad with the false laziness
252 sub _subtract_11mo {
253   my($self, $time) = @_;
254   my ($sec,$min,$hour,$mday,$mon,$year) = (localtime($time) )[0,1,2,3,4,5];
255   $mon -= 11;
256   if ( $mon < 0 ) { $mon+=12; $year--; }
257   timelocal($sec,$min,$hour,$mday,$mon,$year);
258 }
259
260 =item cust_pkg_setup_cost: The total setup costs of packages setup in the period
261
262 'classnum': limit to this package class.
263
264 =cut
265
266 sub cust_pkg_setup_cost {
267   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
268   my $where = '';
269   my $comparison = '';
270   if ( $opt{'classnum'} =~ /^(\d+)$/ ) {
271     if ( $1 == 0 ) {
272       $comparison = 'IS NULL';
273     }
274     else {
275       $comparison = "= $1";
276     }
277     $where = "AND part_pkg.classnum $comparison";
278   }
279   $agentnum ||= $opt{'agentnum'};
280
281   my $total_sql = " SELECT SUM(part_pkg.setup_cost) ";
282   $total_sql .= " FROM cust_pkg 
283              LEFT JOIN cust_main USING ( custnum )
284              LEFT JOIN part_pkg  USING ( pkgpart )
285                   WHERE pkgnum != 0
286                   $where
287                   AND ".$self->in_time_period_and_agent(
288                     $speriod, $eperiod, $agentnum, 'cust_pkg.setup');
289   return $self->scalar_sql($total_sql);
290 }
291
292 =item cust_pkg_recur_cust: the total recur costs of packages in the period
293
294 'classnum': limit to this package class.
295
296 =cut
297
298 sub cust_pkg_recur_cost {
299   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
300   my $where = '';
301   my $comparison = '';
302   if ( $opt{'classnum'} =~ /^(\d+)$/ ) {
303     if ( $1 == 0 ) {
304       $comparison = 'IS NULL';
305     }
306     else {
307       $comparison = "= $1";
308     }
309     $where = " AND part_pkg.classnum $comparison";
310   }
311   $agentnum ||= $opt{'agentnum'};
312   # duplication of in_time_period_and_agent
313   # because we do it a little differently here
314   $where .= " AND cust_main.agentnum = $agentnum" if $agentnum;
315   $where .= " AND ".
316           $FS::CurrentUser::CurrentUser->agentnums_sql('table' => 'cust_main');
317
318   my $total_sql = " SELECT SUM(part_pkg.recur_cost) ";
319   $total_sql .= " FROM cust_pkg
320              LEFT JOIN cust_main USING ( custnum )
321              LEFT JOIN part_pkg  USING ( pkgpart )
322                   WHERE pkgnum != 0
323                   $where
324                   AND cust_pkg.setup < $eperiod
325                   AND (cust_pkg.cancel > $speriod OR cust_pkg.cancel IS NULL)
326                   ";
327   return $self->scalar_sql($total_sql);
328 }
329
330 =item cust_bill_pkg: the total package charges on invoice line items.
331
332 'charges': limit the type of charges included (setup, recur, usage).
333 Should be a string containing one or more of 'S', 'R', or 'U'; if 
334 unspecified, defaults to all three.
335
336 'classnum': limit to this package class.
337
338 'use_override': for line items generated by an add-on package, use the class
339 of the add-on rather than the base package.
340
341 'freq': limit to packages with this frequency.  Currently uses the part_pkg 
342 frequency, so term discounted packages may give odd results.
343
344 'distribute': for non-monthly recurring charges, ignore the invoice 
345 date.  Instead, consider the line item's starting/ending dates.  Determine 
346 the fraction of the line item duration that falls within the specified 
347 interval and return that fraction of the recurring charges.  This is 
348 somewhat experimental.
349
350 'project': enable if this is a projected period.  This is very experimental.
351
352 =cut
353
354 sub cust_bill_pkg {
355   my $self = shift;
356   my( $speriod, $eperiod, $agentnum, %opt ) = @_;
357
358   my %charges = map {$_=>1} split('', $opt{'charges'} || 'SRU');
359
360   my $sum = 0;
361   $sum += $self->cust_bill_pkg_setup(@_) if $charges{S};
362   $sum += $self->cust_bill_pkg_recur(@_) if $charges{R};
363   $sum += $self->cust_bill_pkg_detail(@_) if $charges{U};
364   $sum;
365 }
366
367 my $cust_bill_pkg_join = '
368     LEFT JOIN cust_bill USING ( invnum )
369     LEFT JOIN cust_main USING ( custnum )
370     LEFT JOIN cust_pkg USING ( pkgnum )
371     LEFT JOIN part_pkg USING ( pkgpart )
372     LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart';
373
374 sub cust_bill_pkg_setup {
375   my $self = shift;
376   my ($speriod, $eperiod, $agentnum, %opt) = @_;
377   # no projecting setup fees--use real invoices only
378   # but evaluate this anyway, because the design of projection is that
379   # if there are somehow real setup fees in the future, we want to count
380   # them
381
382   $agentnum ||= $opt{'agentnum'};
383
384   my @where = (
385     'pkgnum != 0',
386     $self->with_classnum($opt{'classnum'}, $opt{'use_override'}),
387     $self->in_time_period_and_agent($speriod, $eperiod, $agentnum),
388   );
389
390   my $total_sql = "SELECT COALESCE(SUM(cust_bill_pkg.setup),0)
391   FROM cust_bill_pkg
392   $cust_bill_pkg_join
393   WHERE " . join(' AND ', grep $_, @where);
394
395   $self->scalar_sql($total_sql);
396 }
397
398 sub cust_bill_pkg_recur {
399   my $self = shift;
400   my ($speriod, $eperiod, $agentnum, %opt) = @_;
401
402   $agentnum ||= $opt{'agentnum'};
403   my $cust_bill_pkg = $opt{'project'} ? 'v_cust_bill_pkg' : 'cust_bill_pkg';
404
405   my @where = (
406     'pkgnum != 0',
407     $self->with_classnum($opt{'classnum'}, $opt{'use_override'}),
408   );
409
410   # subtract all usage from the line item regardless of date
411   my $item_usage;
412   if ( $opt{'project'} ) {
413     $item_usage = 'usage'; #already calculated
414   }
415   else {
416     $item_usage = '( SELECT COALESCE(SUM(amount),0)
417       FROM cust_bill_pkg_detail
418       WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum )';
419   }
420   my $recur_fraction = '';
421
422   if ( $opt{'distribute'} ) {
423     push @where, "cust_main.agentnum = $agentnum" if $agentnum;
424     push @where,
425       "$cust_bill_pkg.sdate < $eperiod",
426       "$cust_bill_pkg.edate > $speriod",
427     ;
428     # the fraction of edate - sdate that's within [speriod, eperiod]
429     $recur_fraction = " * 
430       CAST(LEAST($eperiod, $cust_bill_pkg.edate) - 
431        GREATEST($speriod, $cust_bill_pkg.sdate) AS DECIMAL) / 
432       ($cust_bill_pkg.edate - $cust_bill_pkg.sdate)";
433   }
434   else {
435     # we don't want to have to create v_cust_bill
436     my $_date = $opt{'project'} ? 'v_cust_bill_pkg._date' : 'cust_bill._date';
437     push @where, 
438       $self->in_time_period_and_agent($speriod, $eperiod, $agentnum, $_date);
439   }
440
441   my $total_sql = 'SELECT '.
442   "COALESCE(SUM(($cust_bill_pkg.recur - $item_usage) $recur_fraction),0)
443   FROM $cust_bill_pkg 
444   $cust_bill_pkg_join
445   WHERE ".join(' AND ', grep $_, @where);
446
447   $self->scalar_sql($total_sql);
448 }
449
450 =item cust_bill_pkg_detail: the total usage charges in detail lines.
451
452 Arguments as for C<cust_bill_pkg>, plus:
453
454 'usageclass': limit to this usage class number.
455
456 =cut
457
458 sub cust_bill_pkg_detail {
459   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
460
461   my @where = ( "cust_bill_pkg.pkgnum != 0" );
462
463   $agentnum ||= $opt{'agentnum'};
464
465   push @where,
466     $self->with_classnum($opt{'classnum'}, $opt{'use_override'}),
467     $self->with_usageclass($opt{'usageclass'}),
468     ;
469
470   if ( $opt{'distribute'} ) {
471     # then limit according to the usage time, not the billing date
472     push @where, $self->in_time_period_and_agent($speriod, $eperiod, $agentnum,
473       'cust_bill_pkg_detail.startdate'
474     );
475   }
476   else {
477     push @where, $self->in_time_period_and_agent($speriod, $eperiod, $agentnum,
478       'cust_bill._date'
479     );
480   }
481
482   my $total_sql = " SELECT SUM(amount) ";
483
484   $total_sql .=
485     " / CASE COUNT(cust_pkg.*) WHEN 0 THEN 1 ELSE COUNT(cust_pkg.*) END "
486       if $opt{average_per_cust_pkg};
487
488   $total_sql .=
489     " FROM cust_bill_pkg_detail
490         LEFT JOIN cust_bill_pkg USING ( billpkgnum )
491         LEFT JOIN cust_bill ON cust_bill_pkg.invnum = cust_bill.invnum
492         LEFT JOIN cust_main USING ( custnum )
493         LEFT JOIN cust_pkg ON cust_bill_pkg.pkgnum = cust_pkg.pkgnum
494         LEFT JOIN part_pkg USING ( pkgpart )
495         LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart
496       WHERE ".join( ' AND ', grep $_, @where );
497
498   $self->scalar_sql($total_sql);
499   
500 }
501
502 sub cust_bill_pkg_discount {
503   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
504
505   #my $where = '';
506   #my $comparison = '';
507   #if ( $opt{'classnum'} =~ /^(\d+)$/ ) {
508   #  if ( $1 == 0 ) {
509   #    $comparison = "IS NULL";
510   #  } else {
511   #    $comparison = "= $1";
512   #  }
513   #
514   #  if ( $opt{'use_override'} ) {
515   #    $where = "(
516   #      part_pkg.classnum $comparison AND pkgpart_override IS NULL OR
517   #      override.classnum $comparison AND pkgpart_override IS NOT NULL
518   #    )";
519   #  } else {
520   #    $where = "part_pkg.classnum $comparison";
521   #  }
522   #}
523
524   $agentnum ||= $opt{'agentnum'};
525
526   my $total_sql =
527     " SELECT COALESCE( SUM( cust_bill_pkg_discount.amount ), 0 ) ";
528
529   #$total_sql .=
530   #  " / CASE COUNT(cust_pkg.*) WHEN 0 THEN 1 ELSE COUNT(cust_pkg.*) END "
531   #    if $opt{average_per_cust_pkg};
532
533   $total_sql .=
534     " FROM cust_bill_pkg_discount
535         LEFT JOIN cust_bill_pkg USING ( billpkgnum )
536         LEFT JOIN cust_bill USING ( invnum )
537         LEFT JOIN cust_main USING ( custnum )
538       WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum);
539   #      LEFT JOIN cust_pkg_discount USING ( pkgdiscountnum )
540   #      LEFT JOIN discount USING ( discountnum )
541   #      LEFT JOIN cust_pkg USING ( pkgnum )
542   #      LEFT JOIN part_pkg USING ( pkgpart )
543   #      LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart
544   
545   return $self->scalar_sql($total_sql);
546
547 }
548
549 sub setup_pkg  { shift->pkg_field( @_, 'setup' ); }
550 sub susp_pkg   { shift->pkg_field( @_, 'susp'  ); }
551 sub cancel_pkg { shift->pkg_field( @_, 'cancel'); }
552  
553 sub pkg_field {
554   my( $self, $speriod, $eperiod, $agentnum, $field ) = @_;
555   $self->scalar_sql("
556     SELECT COUNT(*) FROM cust_pkg
557         LEFT JOIN cust_main USING ( custnum )
558       WHERE ". $self->in_time_period_and_agent( $speriod,
559                                                 $eperiod,
560                                                 $agentnum,
561                                                 "cust_pkg.$field",
562                                               )
563   );
564
565 }
566
567 #this is going to be harder..
568 #sub unsusp_pkg {
569 #  my( $self, $speriod, $eperiod, $agentnum ) = @_;
570 #  $self->scalar_sql("
571 #    SELECT COUNT(*) FROM h_cust_pkg
572 #      WHERE 
573 #
574 #}
575
576 sub in_time_period_and_agent {
577   my( $self, $speriod, $eperiod, $agentnum ) = splice(@_, 0, 4);
578   my $col = @_ ? shift() : '_date';
579
580   my $sql = "$col >= $speriod AND $col < $eperiod";
581
582   #agent selection
583   $sql .= " AND cust_main.agentnum = $agentnum"
584     if $agentnum;
585
586   #agent virtualization
587   $sql .= ' AND '.
588           $FS::CurrentUser::CurrentUser->agentnums_sql( 'table'=>'cust_main' );
589
590   $sql;
591 }
592
593 sub for_custnum {
594     my ( $self, %opt ) = @_;
595     return '' unless $opt{'custnum'};
596     $opt{'custnum'} =~ /^\d+$/ ? " and custnum = $opt{custnum} " : '';
597 }
598
599 sub with_classnum {
600   my $self = shift;
601   my ($classnum, $use_override) = @_;
602   return '' unless $classnum =~ /^\d+$/;
603   my $comparison;
604   if ( $classnum == 0 ) {
605     $comparison = 'IS NULL';
606   }
607   else {
608     $comparison = "= $classnum";
609   }
610   if ( $use_override ) {
611     return "(
612       part_pkg.classnum $comparison AND pkgpart_override IS NULL OR
613       override.classnum $comparison AND pkgpart_override IS NOT NULL
614     )";
615   }
616   else {
617     return "part_pkg.classnum $comparison";
618   }
619 }
620
621 sub with_usageclass {
622   my $self = shift;
623   my ($classnum, $use_override) = @_;
624   return '' unless $classnum =~ /^\d+$/;
625   my $comparison;
626   if ( $classnum == 0 ) {
627     $comparison = 'IS NULL';
628   }
629   else {
630     $comparison = "= $classnum";
631   }
632   return "cust_bill_pkg_detail.classnum $comparison";
633 }
634
635 sub scalar_sql {
636   my( $self, $sql ) = ( shift, shift );
637   my $sth = dbh->prepare($sql) or die dbh->errstr;
638   warn "FS::Report::Table\n$sql\n" if $DEBUG;
639   $sth->execute
640     or die "Unexpected error executing statement $sql: ". $sth->errstr;
641   $sth->fetchrow_arrayref->[0] || 0;
642 }
643
644 =back
645
646 =head1 METHODS
647
648 =over 4
649
650 =item init_projection
651
652 Sets up for future projection of all observables on the report.  Currently 
653 this is limited to 'cust_bill_pkg'.
654
655 =cut
656
657 sub init_projection {
658   # this is weird special case stuff--some redesign may be needed 
659   # to use it for anything else
660   my $self = shift;
661
662   if ( driver_name ne 'Pg' ) {
663     # also database-specific for now
664     die "projection reports not supported on this platform";
665   }
666
667   my %items = map {$_ => 1} @{ $self->{items} };
668   if ($items{'cust_bill_pkg'}) {
669     my $dbh = dbh;
670     # v_ for 'virtual'
671     my @sql = (
672       # could use TEMPORARY TABLE but we're already transaction-protected
673       'DROP TABLE IF EXISTS v_cust_bill_pkg',
674       'CREATE TABLE v_cust_bill_pkg ' . 
675        '(LIKE cust_bill_pkg,
676           usage numeric(10,2), _date integer, expire integer)',
677       # XXX this should be smart enough to take only the ones with 
678       # sdate/edate overlapping the ROI, for performance
679       "INSERT INTO v_cust_bill_pkg ( 
680         SELECT cust_bill_pkg.*,
681           (SELECT COALESCE(SUM(amount),0) FROM cust_bill_pkg_detail 
682           WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum),
683           cust_bill._date,
684           cust_pkg.expire
685         FROM cust_bill_pkg $cust_bill_pkg_join
686       )",
687     );
688     foreach my $sql (@sql) {
689       warn "[init_projection] $sql\n" if $DEBUG;
690       $dbh->do($sql) or die $dbh->errstr;
691     }
692   }
693 }
694
695 =item extend_projection START END
696
697 Generates data for the next period of projection.  This will be called 
698 for sequential periods where the END of one equals the START of the next
699 (with no gaps).
700
701 =cut
702
703 sub extend_projection {
704   my $self = shift;
705   my ($speriod, $eperiod) = @_;
706   my %items = map {$_ => 1} @{ $self->{items} };
707   if ($items{'cust_bill_pkg'}) {
708     # append, head-to-tail, new line items identical to any that end within the 
709     # period (and aren't expiring)
710     my @fields = ( FS::cust_bill_pkg->fields, qw( usage _date expire ) );
711     my $insert_fields = join(',', @fields);
712     #advance (sdate, edate) by one billing period
713     foreach (@fields) {
714       if ($_ eq 'edate') {
715         $_ = '(edate + (edate - sdate)) AS edate' #careful of integer overflow
716       }
717       elsif ($_ eq 'sdate') {
718         $_ = 'edate AS sdate'
719       }
720       elsif ($_ eq 'setup') {
721         $_ = '0 AS setup' #because recurring only
722       }
723       elsif ($_ eq '_date') {
724         $_ = '(_date + (edate - sdate)) AS _date'
725       }
726     }
727     my $select_fields = join(',', @fields);
728     my $dbh = dbh;
729     my $sql =
730       "INSERT INTO v_cust_bill_pkg ($insert_fields)
731         SELECT $select_fields FROM v_cust_bill_pkg
732         WHERE edate >= $speriod AND edate < $eperiod 
733               AND recur > 0
734               AND (expire IS NULL OR expire > edate)";
735     warn "[extend_projection] $sql\n" if $DEBUG;
736     my $rows = $dbh->do($sql) or die $dbh->errstr;
737     warn "[extend_projection] $rows rows\n" if $DEBUG;
738   }
739 }
740
741 =head1 BUGS
742
743 Documentation.
744
745 =head1 SEE ALSO
746
747 L<FS::Report::Table::Monthly>, reports in the web interface.
748
749 =cut
750
751 1;