1 package FS::Report::Table;
4 use vars qw( @ISA $DEBUG );
6 use Time::Local qw( timelocal );
7 use FS::UID qw( dbh driver_name );
11 $DEBUG = 0; # turning this on will trace all SQL statements, VERY noisy
12 @ISA = qw( FS::Report );
16 FS::Report::Table - Tables of report data
20 See the more specific report objects, currently only
21 FS::Report::Table::Monthly and FS::Report::Table::Daily.
25 The common interface for an observable named 'foo' is:
27 $report->foo($startdate, $enddate, $agentnum, %options)
29 This returns a scalar value for foo, over the period from
30 $startdate to $enddate, limited to agent $agentnum, subject to
35 =item signups: The number of customers signed up. Options are "refnum"
36 (limit by advertising source) and "indirect" (boolean, tells us to limit
37 to customers that have a referral_custnum that matches the advertising source).
42 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
43 my @where = ( $self->in_time_period_and_agent($speriod, $eperiod, $agentnum,
44 'cust_main.signupdate')
47 if ( $opt{'indirect'} ) {
48 $join = " JOIN cust_main AS referring_cust_main".
49 " ON (cust_main.referral_custnum = referring_cust_main.custnum)";
51 if ( $opt{'refnum'} ) {
52 push @where, "referring_cust_main.refnum = ".$opt{'refnum'};
55 elsif ( $opt{'refnum'} ) {
56 push @where, "refnum = ".$opt{'refnum'};
59 push @where, $self->with_cust_classnum(%opt);
62 "SELECT COUNT(*) FROM cust_main $join WHERE ".join(' AND ', @where)
66 =item invoiced: The total amount charged on all invoices.
70 sub invoiced { #invoiced
71 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
73 my $sql = 'SELECT SUM(cust_bill.charged) FROM cust_bill';
74 if ( $opt{'setuprecur'} ) {
76 FS::cust_bill_pkg->charged_sql($speriod, $eperiod, %opt).
77 ') FROM cust_bill_pkg JOIN cust_bill USING (invnum)';
82 LEFT JOIN cust_main USING ( custnum )
83 WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum).
89 =item netsales: invoiced - netcredits
93 sub netsales { #net sales
94 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
96 $self->invoiced( $speriod, $eperiod, $agentnum, %opt)
97 - $self->netcredits($speriod, $eperiod, $agentnum, %opt);
100 =item cashflow: payments - refunds
105 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
107 $self->payments($speriod, $eperiod, $agentnum, %opt)
108 - $self->refunds( $speriod, $eperiod, $agentnum, %opt);
111 =item netcashflow: payments - netrefunds
116 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
118 $self->receipts( $speriod, $eperiod, $agentnum, %opt)
119 - $self->netrefunds( $speriod, $eperiod, $agentnum, %opt);
122 =item payments: The sum of payments received in the period.
127 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
131 LEFT JOIN cust_main USING ( custnum )
132 WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum).
133 $self->for_opts(%opt)
137 =item credits: The sum of credits issued in the period.
142 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
146 LEFT JOIN cust_main USING ( custnum )
147 WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum).
148 $self->for_opts(%opt)
152 =item refunds: The sum of refunds paid in the period.
157 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
161 LEFT JOIN cust_main USING ( custnum )
162 WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum).
163 $self->for_opts(%opt)
167 =item netcredits: The sum of credit applications to invoices in the period.
172 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
174 my $sql = 'SELECT SUM(cust_credit_bill.amount) FROM cust_credit_bill';
175 if ( $opt{'setuprecur'} ) {
176 $sql = 'SELECT SUM('.
177 FS::cust_bill_pkg->credited_sql($speriod, $eperiod, %opt).
178 ') FROM cust_bill_pkg';
183 LEFT JOIN cust_bill USING ( invnum )
184 LEFT JOIN cust_main USING ( custnum )
185 WHERE ". $self->in_time_period_and_agent( $speriod,
190 $self->for_opts(%opt)
194 =item receipts: The sum of payment applications to invoices in the period.
198 sub receipts { #net payments
199 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
201 my $sql = 'SELECT SUM(cust_bill_pay.amount) FROM cust_bill_pay';
202 if ( $opt{'setuprecur'} ) {
203 $sql = 'SELECT SUM('.
204 FS::cust_bill_pkg->paid_sql($speriod, $eperiod, %opt).
205 ') FROM cust_bill_pkg';
210 LEFT JOIN cust_bill USING ( invnum )
211 LEFT JOIN cust_main USING ( custnum )
212 WHERE ". $self->in_time_period_and_agent( $speriod,
217 $self->for_opts(%opt)
221 =item netrefunds: The sum of refund applications to credits in the period.
226 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
228 SELECT SUM(cust_credit_refund.amount)
229 FROM cust_credit_refund
230 LEFT JOIN cust_credit USING ( crednum )
231 LEFT JOIN cust_main USING ( custnum )
232 WHERE ". $self->in_time_period_and_agent( $speriod,
237 $self->for_opts(%opt)
243 #these should be auto-generated or $AUTOLOADed or something
245 my( $self, $speriod, $eperiod, $agentnum ) = @_;
246 $speriod = $self->_subtract_11mo($speriod);
247 $self->invoiced($speriod, $eperiod, $agentnum);
251 my( $self, $speriod, $eperiod, $agentnum ) = @_;
252 $speriod = $self->_subtract_11mo($speriod);
253 $self->netsales($speriod, $eperiod, $agentnum);
257 my( $self, $speriod, $eperiod, $agentnum ) = @_;
258 $speriod = $self->_subtract_11mo($speriod);
259 $self->receipts($speriod, $eperiod, $agentnum);
263 my( $self, $speriod, $eperiod, $agentnum ) = @_;
264 $speriod = $self->_subtract_11mo($speriod);
265 $self->payments($speriod, $eperiod, $agentnum);
269 my( $self, $speriod, $eperiod, $agentnum ) = @_;
270 $speriod = $self->_subtract_11mo($speriod);
271 $self->credits($speriod, $eperiod, $agentnum);
274 sub netcredits_12mo {
275 my( $self, $speriod, $eperiod, $agentnum ) = @_;
276 $speriod = $self->_subtract_11mo($speriod);
277 $self->netcredits($speriod, $eperiod, $agentnum);
281 my( $self, $speriod, $eperiod, $agentnum ) = @_;
282 $speriod = $self->_subtract_11mo($speriod);
283 $self->cashflow($speriod, $eperiod, $agentnum);
286 sub netcashflow_12mo {
287 my( $self, $speriod, $eperiod, $agentnum ) = @_;
288 $speriod = $self->_subtract_11mo($speriod);
289 $self->cashflow($speriod, $eperiod, $agentnum);
293 my( $self, $speriod, $eperiod, $agentnum ) = @_;
294 $speriod = $self->_subtract_11mo($speriod);
295 $self->refunds($speriod, $eperiod, $agentnum);
298 sub netrefunds_12mo {
299 my( $self, $speriod, $eperiod, $agentnum ) = @_;
300 $speriod = $self->_subtract_11mo($speriod);
301 $self->netrefunds($speriod, $eperiod, $agentnum);
305 #not being too bad with the false laziness
307 my($self, $time) = @_;
308 my ($sec,$min,$hour,$mday,$mon,$year) = (localtime($time) )[0,1,2,3,4,5];
310 if ( $mon < 0 ) { $mon+=12; $year--; }
311 timelocal($sec,$min,$hour,$mday,$mon,$year);
314 =item cust_pkg_setup_cost: The total setup costs of packages setup in the period
316 'classnum': limit to this package class.
320 sub cust_pkg_setup_cost {
321 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
324 if ( $opt{'classnum'} ne '' ) {
325 my $classnums = $opt{'classnum'};
326 $classnums = [ $classnums ] if !ref($classnums);
327 @$classnums = grep /^\d+$/, @$classnums;
328 $where .= ' AND COALESCE(part_pkg.classnum,0) IN ('. join(',', @$classnums).
332 $agentnum ||= $opt{'agentnum'};
334 my $total_sql = " SELECT SUM(part_pkg.setup_cost) ";
335 $total_sql .= " FROM cust_pkg
336 LEFT JOIN cust_main USING ( custnum )
337 LEFT JOIN part_pkg USING ( pkgpart )
340 AND ".$self->in_time_period_and_agent(
341 $speriod, $eperiod, $agentnum, 'cust_pkg.setup');
342 return $self->scalar_sql($total_sql);
345 =item cust_pkg_recur_cust: the total recur costs of packages in the period
347 'classnum': limit to this package class.
351 sub cust_pkg_recur_cost {
352 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
355 if ( $opt{'classnum'} ne '' ) {
356 my $classnums = $opt{'classnum'};
357 $classnums = [ $classnums ] if !ref($classnums);
358 @$classnums = grep /^\d+$/, @$classnums;
359 $where .= ' AND COALESCE(part_pkg.classnum,0) IN ('. join(',', @$classnums).
363 $agentnum ||= $opt{'agentnum'};
364 # duplication of in_time_period_and_agent
365 # because we do it a little differently here
366 $where .= " AND cust_main.agentnum = $agentnum" if $agentnum;
368 $FS::CurrentUser::CurrentUser->agentnums_sql('table' => 'cust_main');
370 my $total_sql = " SELECT SUM(part_pkg.recur_cost) ";
371 $total_sql .= " FROM cust_pkg
372 LEFT JOIN cust_main USING ( custnum )
373 LEFT JOIN part_pkg USING ( pkgpart )
376 AND cust_pkg.setup < $eperiod
377 AND (cust_pkg.cancel > $speriod OR cust_pkg.cancel IS NULL)
379 return $self->scalar_sql($total_sql);
382 =item cust_bill_pkg: the total package charges on invoice line items.
384 'charges': limit the type of charges included (setup, recur, usage).
385 Should be a string containing one or more of 'S', 'R', or 'U'; if
386 unspecified, defaults to all three.
388 'classnum': limit to this package class.
390 'use_override': for line items generated by an add-on package, use the class
391 of the add-on rather than the base package.
393 'freq': limit to packages with this frequency. Currently uses the part_pkg
394 frequency, so term discounted packages may give odd results.
396 'distribute': for non-monthly recurring charges, ignore the invoice
397 date. Instead, consider the line item's starting/ending dates. Determine
398 the fraction of the line item duration that falls within the specified
399 interval and return that fraction of the recurring charges. This is
400 somewhat experimental.
402 'project': enable if this is a projected period. This is very experimental.
408 my( $speriod, $eperiod, $agentnum, %opt ) = @_;
410 my %charges = map {$_=>1} split('', $opt{'charges'} || 'SRU');
413 $sum += $self->cust_bill_pkg_setup(@_) if $charges{S};
414 $sum += $self->cust_bill_pkg_recur(@_) if $charges{R};
415 $sum += $self->cust_bill_pkg_detail(@_) if $charges{U};
419 my $cust_bill_pkg_join = '
420 LEFT JOIN cust_bill USING ( invnum )
421 LEFT JOIN cust_main USING ( custnum )
422 LEFT JOIN cust_pkg USING ( pkgnum )
423 LEFT JOIN part_pkg USING ( pkgpart )
424 LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart';
426 sub cust_bill_pkg_setup {
428 my ($speriod, $eperiod, $agentnum, %opt) = @_;
429 # no projecting setup fees--use real invoices only
430 # but evaluate this anyway, because the design of projection is that
431 # if there are somehow real setup fees in the future, we want to count
434 $agentnum ||= $opt{'agentnum'};
438 $self->with_classnum($opt{'classnum'}, $opt{'use_override'}),
439 $self->with_report_option($opt{'report_optionnum'}, $opt{'use_override'}),
440 $self->in_time_period_and_agent($speriod, $eperiod, $agentnum),
443 # yuck, false laziness
444 push @where, "cust_main.refnum = ". $opt{'refnum'} if $opt{'refnum'};
446 push @where, $self->with_cust_classnum(%opt);
448 my $total_sql = "SELECT COALESCE(SUM(cust_bill_pkg.setup),0)
451 WHERE " . join(' AND ', grep $_, @where);
453 $self->scalar_sql($total_sql);
456 sub cust_bill_pkg_recur {
458 my ($speriod, $eperiod, $agentnum, %opt) = @_;
460 $agentnum ||= $opt{'agentnum'};
461 my $cust_bill_pkg = $opt{'project'} ? 'v_cust_bill_pkg' : 'cust_bill_pkg';
465 $self->with_classnum($opt{'classnum'}, $opt{'use_override'}),
466 $self->with_report_option($opt{'report_optionnum'}, $opt{'use_override'}),
469 push @where, 'cust_main.refnum = '. $opt{'refnum'} if $opt{'refnum'};
471 push @where, $self->with_cust_classnum(%opt);
473 # subtract all usage from the line item regardless of date
475 if ( $opt{'project'} ) {
476 $item_usage = 'usage'; #already calculated
479 $item_usage = '( SELECT COALESCE(SUM(amount),0)
480 FROM cust_bill_pkg_detail
481 WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum )';
483 my $recur_fraction = '';
485 if ( $opt{'distribute'} ) {
486 push @where, "cust_main.agentnum = $agentnum" if $agentnum;
488 "$cust_bill_pkg.sdate < $eperiod",
489 "$cust_bill_pkg.edate >= $speriod",
491 # the fraction of edate - sdate that's within [speriod, eperiod]
492 $recur_fraction = " *
493 CAST(LEAST($eperiod, $cust_bill_pkg.edate) -
494 GREATEST($speriod, $cust_bill_pkg.sdate) AS DECIMAL) /
495 ($cust_bill_pkg.edate - $cust_bill_pkg.sdate)";
498 # we don't want to have to create v_cust_bill
499 my $_date = $opt{'project'} ? 'v_cust_bill_pkg._date' : 'cust_bill._date';
501 $self->in_time_period_and_agent($speriod, $eperiod, $agentnum, $_date);
504 my $total_sql = 'SELECT '.
505 "COALESCE(SUM(($cust_bill_pkg.recur - $item_usage) $recur_fraction),0)
508 WHERE ".join(' AND ', grep $_, @where);
510 $self->scalar_sql($total_sql);
513 =item cust_bill_pkg_detail: the total usage charges in detail lines.
515 Arguments as for C<cust_bill_pkg>, plus:
517 'usageclass': limit to this usage class number.
521 sub cust_bill_pkg_detail {
522 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
524 my @where = ( "cust_bill_pkg.pkgnum != 0" );
526 push @where, 'cust_main.refnum = '. $opt{'refnum'} if $opt{'refnum'};
528 push @where, $self->with_cust_classnum(%opt);
530 $agentnum ||= $opt{'agentnum'};
533 $self->with_classnum($opt{'classnum'}, $opt{'use_override'}),
534 $self->with_usageclass($opt{'usageclass'}),
535 $self->with_report_option($opt{'report_optionnum'}, $opt{'use_override'}),
538 if ( $opt{'distribute'} ) {
539 # then limit according to the usage time, not the billing date
540 push @where, $self->in_time_period_and_agent($speriod, $eperiod, $agentnum,
541 'cust_bill_pkg_detail.startdate'
545 push @where, $self->in_time_period_and_agent($speriod, $eperiod, $agentnum,
550 my $total_sql = " SELECT SUM(amount) ";
553 " / CASE COUNT(cust_pkg.*) WHEN 0 THEN 1 ELSE COUNT(cust_pkg.*) END "
554 if $opt{average_per_cust_pkg};
557 " FROM cust_bill_pkg_detail
558 LEFT JOIN cust_bill_pkg USING ( billpkgnum )
559 LEFT JOIN cust_bill ON cust_bill_pkg.invnum = cust_bill.invnum
560 LEFT JOIN cust_main USING ( custnum )
561 LEFT JOIN cust_pkg ON cust_bill_pkg.pkgnum = cust_pkg.pkgnum
562 LEFT JOIN part_pkg USING ( pkgpart )
563 LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart
564 WHERE ".join( ' AND ', grep $_, @where );
566 $self->scalar_sql($total_sql);
570 sub cust_bill_pkg_discount {
571 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
573 #need to do this the new multi-classnum way if it gets re-enabled
575 #my $comparison = '';
576 #if ( $opt{'classnum'} =~ /^(\d+)$/ ) {
578 # $comparison = "IS NULL";
580 # $comparison = "= $1";
583 # if ( $opt{'use_override'} ) {
585 # part_pkg.classnum $comparison AND pkgpart_override IS NULL OR
586 # override.classnum $comparison AND pkgpart_override IS NOT NULL
589 # $where = "part_pkg.classnum $comparison";
593 $agentnum ||= $opt{'agentnum'};
596 " SELECT COALESCE( SUM( cust_bill_pkg_discount.amount ), 0 ) ";
599 # " / CASE COUNT(cust_pkg.*) WHEN 0 THEN 1 ELSE COUNT(cust_pkg.*) END "
600 # if $opt{average_per_cust_pkg};
603 " FROM cust_bill_pkg_discount
604 LEFT JOIN cust_bill_pkg USING ( billpkgnum )
605 LEFT JOIN cust_bill USING ( invnum )
606 LEFT JOIN cust_main USING ( custnum )
607 WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum);
608 # LEFT JOIN cust_pkg_discount USING ( pkgdiscountnum )
609 # LEFT JOIN discount USING ( discountnum )
610 # LEFT JOIN cust_pkg USING ( pkgnum )
611 # LEFT JOIN part_pkg USING ( pkgpart )
612 # LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart
614 return $self->scalar_sql($total_sql);
618 sub setup_pkg { shift->pkg_field( 'setup', @_ ); }
619 sub susp_pkg { shift->pkg_field( 'susp', @_ ); }
620 sub cancel_pkg { shift->pkg_field( 'cancel', @_ ); }
623 my( $self, $field, $speriod, $eperiod, $agentnum ) = @_;
625 SELECT COUNT(*) FROM cust_pkg
626 LEFT JOIN cust_main USING ( custnum )
627 WHERE ". $self->in_time_period_and_agent( $speriod,
636 #this is going to be harder..
638 # my( $self, $speriod, $eperiod, $agentnum ) = @_;
639 # $self->scalar_sql("
640 # SELECT COUNT(*) FROM h_cust_pkg
645 sub in_time_period_and_agent {
646 my( $self, $speriod, $eperiod, $agentnum ) = splice(@_, 0, 4);
647 my $col = @_ ? shift() : '_date';
649 my $sql = "$col >= $speriod AND $col < $eperiod";
652 $sql .= " AND cust_main.agentnum = $agentnum"
655 #agent virtualization
657 $FS::CurrentUser::CurrentUser->agentnums_sql( 'table'=>'cust_main' );
663 my ( $self, %opt ) = @_;
665 if ( $opt{'custnum'} =~ /^(\d+)$/ ) {
666 $sql .= " and custnum = $1 ";
668 if ( $opt{'refnum'} =~ /^(\d+)$/ ) {
669 $sql .= " and refnum = $1 ";
671 if ( my $where = $self->with_cust_classnum(%opt) ) {
672 $sql .= " and $where";
679 my ($self, $classnum, $use_override) = @_;
680 return '' if $classnum eq '';
682 $classnum = [ $classnum ] if !ref($classnum);
683 @$classnum = grep /^\d+$/, @$classnum;
684 my $in = 'IN ('. join(',', @$classnum). ')';
686 if ( $use_override ) {
688 ( COALESCE(part_pkg.classnum, 0) $in AND pkgpart_override IS NULL)
689 OR ( COALESCE(override.classnum, 0) $in AND pkgpart_override IS NOT NULL )
692 "COALESCE(part_pkg.classnum, 0) $in";
696 sub with_usageclass {
698 my ($classnum, $use_override) = @_;
699 return '' unless $classnum =~ /^\d+$/;
701 if ( $classnum == 0 ) {
702 $comparison = 'IS NULL';
705 $comparison = "= $classnum";
707 return "cust_bill_pkg_detail.classnum $comparison";
710 sub with_report_option {
711 my ($self, $num, $use_override) = @_;
712 # $num can be a single number, or a comma-delimited list of numbers,
713 # or an arrayref. 0 matches the empty set
714 # or the word 'multiple' for all packages with more than one report class
715 return '' if !defined($num);
717 $num = join(',', @$num) if ref($num);
719 # stringify the set of report options for each pkgpart
720 my $table = $use_override ? 'override' : 'part_pkg';
722 SELECT replace(optionname, 'report_option_', '') AS num
724 WHERE optionname like 'report_option_%'
725 AND part_pkg_option.pkgpart = $table.pkgpart
729 if ( $num eq 'multiple' ) {
730 $comparison = "(SELECT COUNT(*) FROM ($subselect) AS x) > 1";
733 my @num = split(/\s*,\s*/, $num);
735 #$comparison = "(SELECT COALESCE(string_agg(num, ','), '') FROM ( #Pg 9-ism
736 $comparison = "(SELECT COALESCE(array_to_string(array_agg(num), ','), '')
737 FROM ($subselect) AS x
738 ) = '". join(',', grep $_, @num). "'";
740 $comparison = "( $comparison OR NOT EXISTS ($subselect) )"
744 if ( $use_override ) {
745 # then also allow the non-override package to match
746 $comparison = "( $comparison OR " . $self->with_report_option($num) . ")";
751 sub with_cust_classnum {
752 my ($self, %opt) = @_;
753 if ( $opt{'cust_classnum'} ) {
754 my $classnums = $opt{'cust_classnum'};
755 $classnums = [ $classnums ] if !ref($classnums);
756 @$classnums = grep /^\d+$/, @$classnums;
757 return 'cust_main.classnum in('. join(',',@$classnums) .')'
765 my( $self, $sql ) = ( shift, shift );
766 my $sth = dbh->prepare($sql) or die dbh->errstr;
767 warn "FS::Report::Table\n$sql\n" if $DEBUG;
769 or die "Unexpected error executing statement $sql: ". $sth->errstr;
770 $sth->fetchrow_arrayref->[0] || 0;
779 =item init_projection
781 Sets up for future projection of all observables on the report. Currently
782 this is limited to 'cust_bill_pkg'.
786 sub init_projection {
787 # this is weird special case stuff--some redesign may be needed
788 # to use it for anything else
791 if ( driver_name ne 'Pg' ) {
792 # also database-specific for now
793 die "projection reports not supported on this platform";
796 my %items = map {$_ => 1} @{ $self->{items} };
797 if ($items{'cust_bill_pkg'}) {
801 # could use TEMPORARY TABLE but we're already transaction-protected
802 'DROP TABLE IF EXISTS v_cust_bill_pkg',
803 'CREATE TABLE v_cust_bill_pkg ' .
804 '(LIKE cust_bill_pkg,
805 usage numeric(10,2), _date integer, expire integer)',
806 # XXX this should be smart enough to take only the ones with
807 # sdate/edate overlapping the ROI, for performance
808 "INSERT INTO v_cust_bill_pkg (
809 SELECT cust_bill_pkg.*,
810 (SELECT COALESCE(SUM(amount),0) FROM cust_bill_pkg_detail
811 WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum),
814 FROM cust_bill_pkg $cust_bill_pkg_join
817 foreach my $sql (@sql) {
818 warn "[init_projection] $sql\n" if $DEBUG;
819 $dbh->do($sql) or die $dbh->errstr;
824 =item extend_projection START END
826 Generates data for the next period of projection. This will be called
827 for sequential periods where the END of one equals the START of the next
832 sub extend_projection {
834 my ($speriod, $eperiod) = @_;
835 my %items = map {$_ => 1} @{ $self->{items} };
836 if ($items{'cust_bill_pkg'}) {
838 # Find all line items that end after the start of the period (and have
839 # recurring fees, and don't expire before they end). Choose the latest
840 # one for each package. If it ends before the end of the period, copy
841 # it forward by one billing period.
842 # Repeat this until the latest line item for each package no longer ends
843 # within the period. This is certain to happen in finitely many
844 # iterations as long as freq > 0.
845 # - Pg only, obviously.
846 # - Gives bad results if freq_override is used.
847 my @fields = ( FS::cust_bill_pkg->fields, qw( usage _date expire ) );
848 my $insert_fields = join(',', @fields);
849 my $add_freq = sub { # emulate FS::part_pkg::add_freq
851 "EXTRACT( EPOCH FROM TO_TIMESTAMP($field) + (CASE WHEN freq ~ E'\\\\D' ".
852 "THEN freq ELSE freq || 'mon' END)::INTERVAL) AS $field";
856 $_ = $add_freq->('edate');
858 elsif ($_ eq 'sdate') {
859 $_ = 'edate AS sdate'
861 elsif ($_ eq 'setup') {
862 $_ = '0 AS setup' #because recurring only
864 elsif ($_ eq '_date') {
865 $_ = $add_freq->('_date');
868 my $select_fields = join(',', @fields);
871 # Subquery here because we need to DISTINCT the whole set, select the
872 # latest charge per pkgnum, and _then_ check edate < $eperiod
873 # and edate < expire.
874 "INSERT INTO v_cust_bill_pkg ($insert_fields)
875 SELECT $select_fields FROM (
876 SELECT DISTINCT ON (pkgnum) * FROM v_cust_bill_pkg
877 WHERE edate >= $speriod
881 ORDER BY pkgnum, edate DESC
883 WHERE edate < $eperiod AND (edate < expire OR expire IS NULL)";
886 warn "[extend_projection] $sql\n" if $DEBUG;
887 $rows = $dbh->do($sql) or die $dbh->errstr;
888 warn "[extend_projection] $rows rows\n" if $DEBUG;
899 L<FS::Report::Table::Monthly>, reports in the web interface.