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'};
60 "SELECT COUNT(*) FROM cust_main $join WHERE ".join(' AND ', @where)
64 =item invoiced: The total amount charged on all invoices.
68 sub invoiced { #invoiced
69 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
74 LEFT JOIN cust_main USING ( custnum )
75 WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum)
76 . (%opt ? $self->for_custnum(%opt) : '')
81 =item netsales: invoiced - netcredits
85 sub netsales { #net sales
86 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
88 $self->invoiced($speriod,$eperiod,$agentnum,%opt)
89 - $self->netcredits($speriod,$eperiod,$agentnum,%opt);
92 =item cashflow: payments - refunds
97 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
99 $self->payments($speriod, $eperiod, $agentnum, %opt)
100 - $self->refunds( $speriod, $eperiod, $agentnum, %opt);
103 =item netcashflow: payments - netrefunds
108 my( $self, $speriod, $eperiod, $agentnum ) = @_;
110 $self->receipts($speriod, $eperiod, $agentnum)
111 - $self->netrefunds( $speriod, $eperiod, $agentnum);
114 =item payments: The sum of payments received in the period.
119 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
123 LEFT JOIN cust_main USING ( custnum )
124 WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum)
125 . (%opt ? $self->for_custnum(%opt) : '')
129 =item credits: The sum of credits issued in the period.
134 my( $self, $speriod, $eperiod, $agentnum ) = @_;
138 LEFT JOIN cust_main USING ( custnum )
139 WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum)
143 =item refunds: The sum of refunds paid in the period.
148 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
152 LEFT JOIN cust_main USING ( custnum )
153 WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum)
154 . (%opt ? $self->for_custnum(%opt) : '')
158 =item netcredits: The sum of credit applications to invoices in the period.
163 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
165 SELECT SUM(cust_credit_bill.amount)
166 FROM cust_credit_bill
167 LEFT JOIN cust_bill USING ( invnum )
168 LEFT JOIN cust_main USING ( custnum )
169 WHERE ". $self->in_time_period_and_agent( $speriod,
174 . (%opt ? $self->for_custnum(%opt) : '')
178 =item receipts: The sum of payment applications to invoices in the period.
182 sub receipts { #net payments
183 my( $self, $speriod, $eperiod, $agentnum ) = @_;
185 SELECT SUM(cust_bill_pay.amount)
187 LEFT JOIN cust_bill USING ( invnum )
188 LEFT JOIN cust_main USING ( custnum )
189 WHERE ". $self->in_time_period_and_agent( $speriod,
197 =item netrefunds: The sum of refund applications to credits in the period.
202 my( $self, $speriod, $eperiod, $agentnum ) = @_;
204 SELECT SUM(cust_credit_refund.amount)
205 FROM cust_credit_refund
206 LEFT JOIN cust_credit USING ( crednum )
207 LEFT JOIN cust_main USING ( custnum )
208 WHERE ". $self->in_time_period_and_agent( $speriod,
218 #these should be auto-generated or $AUTOLOADed or something
220 my( $self, $speriod, $eperiod, $agentnum ) = @_;
221 $speriod = $self->_subtract_11mo($speriod);
222 $self->invoiced($speriod, $eperiod, $agentnum);
226 my( $self, $speriod, $eperiod, $agentnum ) = @_;
227 $speriod = $self->_subtract_11mo($speriod);
228 $self->netsales($speriod, $eperiod, $agentnum);
232 my( $self, $speriod, $eperiod, $agentnum ) = @_;
233 $speriod = $self->_subtract_11mo($speriod);
234 $self->receipts($speriod, $eperiod, $agentnum);
238 my( $self, $speriod, $eperiod, $agentnum ) = @_;
239 $speriod = $self->_subtract_11mo($speriod);
240 $self->payments($speriod, $eperiod, $agentnum);
244 my( $self, $speriod, $eperiod, $agentnum ) = @_;
245 $speriod = $self->_subtract_11mo($speriod);
246 $self->credits($speriod, $eperiod, $agentnum);
249 sub netcredits_12mo {
250 my( $self, $speriod, $eperiod, $agentnum ) = @_;
251 $speriod = $self->_subtract_11mo($speriod);
252 $self->netcredits($speriod, $eperiod, $agentnum);
256 my( $self, $speriod, $eperiod, $agentnum ) = @_;
257 $speriod = $self->_subtract_11mo($speriod);
258 $self->cashflow($speriod, $eperiod, $agentnum);
261 sub netcashflow_12mo {
262 my( $self, $speriod, $eperiod, $agentnum ) = @_;
263 $speriod = $self->_subtract_11mo($speriod);
264 $self->cashflow($speriod, $eperiod, $agentnum);
268 my( $self, $speriod, $eperiod, $agentnum ) = @_;
269 $speriod = $self->_subtract_11mo($speriod);
270 $self->refunds($speriod, $eperiod, $agentnum);
273 sub netrefunds_12mo {
274 my( $self, $speriod, $eperiod, $agentnum ) = @_;
275 $speriod = $self->_subtract_11mo($speriod);
276 $self->netrefunds($speriod, $eperiod, $agentnum);
280 #not being too bad with the false laziness
282 my($self, $time) = @_;
283 my ($sec,$min,$hour,$mday,$mon,$year) = (localtime($time) )[0,1,2,3,4,5];
285 if ( $mon < 0 ) { $mon+=12; $year--; }
286 timelocal($sec,$min,$hour,$mday,$mon,$year);
289 =item cust_pkg_setup_cost: The total setup costs of packages setup in the period
291 'classnum': limit to this package class.
295 sub cust_pkg_setup_cost {
296 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
299 if ( $opt{'classnum'} =~ /^(\d+)$/ ) {
301 $comparison = 'IS NULL';
304 $comparison = "= $1";
306 $where = "AND part_pkg.classnum $comparison";
308 $agentnum ||= $opt{'agentnum'};
310 my $total_sql = " SELECT SUM(part_pkg.setup_cost) ";
311 $total_sql .= " FROM cust_pkg
312 LEFT JOIN cust_main USING ( custnum )
313 LEFT JOIN part_pkg USING ( pkgpart )
316 AND ".$self->in_time_period_and_agent(
317 $speriod, $eperiod, $agentnum, 'cust_pkg.setup');
318 return $self->scalar_sql($total_sql);
321 =item cust_pkg_recur_cust: the total recur costs of packages in the period
323 'classnum': limit to this package class.
327 sub cust_pkg_recur_cost {
328 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
331 if ( $opt{'classnum'} =~ /^(\d+)$/ ) {
333 $comparison = 'IS NULL';
336 $comparison = "= $1";
338 $where = " AND part_pkg.classnum $comparison";
340 $agentnum ||= $opt{'agentnum'};
341 # duplication of in_time_period_and_agent
342 # because we do it a little differently here
343 $where .= " AND cust_main.agentnum = $agentnum" if $agentnum;
345 $FS::CurrentUser::CurrentUser->agentnums_sql('table' => 'cust_main');
347 my $total_sql = " SELECT SUM(part_pkg.recur_cost) ";
348 $total_sql .= " FROM cust_pkg
349 LEFT JOIN cust_main USING ( custnum )
350 LEFT JOIN part_pkg USING ( pkgpart )
353 AND cust_pkg.setup < $eperiod
354 AND (cust_pkg.cancel > $speriod OR cust_pkg.cancel IS NULL)
356 return $self->scalar_sql($total_sql);
359 =item cust_bill_pkg: the total package charges on invoice line items.
361 'charges': limit the type of charges included (setup, recur, usage).
362 Should be a string containing one or more of 'S', 'R', or 'U'; if
363 unspecified, defaults to all three.
365 'classnum': limit to this package class.
367 'use_override': for line items generated by an add-on package, use the class
368 of the add-on rather than the base package.
370 'freq': limit to packages with this frequency. Currently uses the part_pkg
371 frequency, so term discounted packages may give odd results.
373 'distribute': for non-monthly recurring charges, ignore the invoice
374 date. Instead, consider the line item's starting/ending dates. Determine
375 the fraction of the line item duration that falls within the specified
376 interval and return that fraction of the recurring charges. This is
377 somewhat experimental.
379 'project': enable if this is a projected period. This is very experimental.
385 my( $speriod, $eperiod, $agentnum, %opt ) = @_;
387 my %charges = map {$_=>1} split('', $opt{'charges'} || 'SRU');
390 $sum += $self->cust_bill_pkg_setup(@_) if $charges{S};
391 $sum += $self->cust_bill_pkg_recur(@_) if $charges{R};
392 $sum += $self->cust_bill_pkg_detail(@_) if $charges{U};
396 my $cust_bill_pkg_join = '
397 LEFT JOIN cust_bill USING ( invnum )
398 LEFT JOIN cust_main USING ( custnum )
399 LEFT JOIN cust_pkg USING ( pkgnum )
400 LEFT JOIN part_pkg USING ( pkgpart )
401 LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart';
403 sub cust_bill_pkg_setup {
405 my ($speriod, $eperiod, $agentnum, %opt) = @_;
406 # no projecting setup fees--use real invoices only
407 # but evaluate this anyway, because the design of projection is that
408 # if there are somehow real setup fees in the future, we want to count
411 $agentnum ||= $opt{'agentnum'};
415 $self->with_classnum($opt{'classnum'}, $opt{'use_override'}),
416 $self->in_time_period_and_agent($speriod, $eperiod, $agentnum),
419 push @where, 'cust_main.refnum = '. $opt{'refnum'} if $opt{'refnum'};
421 my $total_sql = "SELECT COALESCE(SUM(cust_bill_pkg.setup),0)
424 WHERE " . join(' AND ', grep $_, @where);
426 $self->scalar_sql($total_sql);
429 sub cust_bill_pkg_recur {
431 my ($speriod, $eperiod, $agentnum, %opt) = @_;
433 $agentnum ||= $opt{'agentnum'};
434 my $cust_bill_pkg = $opt{'project'} ? 'v_cust_bill_pkg' : 'cust_bill_pkg';
438 $self->with_classnum($opt{'classnum'}, $opt{'use_override'}),
441 push @where, 'cust_main.refnum = '. $opt{'refnum'} if $opt{'refnum'};
443 # subtract all usage from the line item regardless of date
445 if ( $opt{'project'} ) {
446 $item_usage = 'usage'; #already calculated
449 $item_usage = '( SELECT COALESCE(SUM(amount),0)
450 FROM cust_bill_pkg_detail
451 WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum )';
453 my $recur_fraction = '';
455 if ( $opt{'distribute'} ) {
456 push @where, "cust_main.agentnum = $agentnum" if $agentnum;
458 "$cust_bill_pkg.sdate < $eperiod",
459 "$cust_bill_pkg.edate >= $speriod",
461 # the fraction of edate - sdate that's within [speriod, eperiod]
462 $recur_fraction = " *
463 CAST(LEAST($eperiod, $cust_bill_pkg.edate) -
464 GREATEST($speriod, $cust_bill_pkg.sdate) AS DECIMAL) /
465 ($cust_bill_pkg.edate - $cust_bill_pkg.sdate)";
468 # we don't want to have to create v_cust_bill
469 my $_date = $opt{'project'} ? 'v_cust_bill_pkg._date' : 'cust_bill._date';
471 $self->in_time_period_and_agent($speriod, $eperiod, $agentnum, $_date);
474 my $total_sql = 'SELECT '.
475 "COALESCE(SUM(($cust_bill_pkg.recur - $item_usage) $recur_fraction),0)
478 WHERE ".join(' AND ', grep $_, @where);
480 $self->scalar_sql($total_sql);
483 =item cust_bill_pkg_detail: the total usage charges in detail lines.
485 Arguments as for C<cust_bill_pkg>, plus:
487 'usageclass': limit to this usage class number.
491 sub cust_bill_pkg_detail {
492 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
494 my @where = ( "cust_bill_pkg.pkgnum != 0" );
496 push @where, 'cust_main.refnum = '. $opt{'refnum'} if $opt{'refnum'};
498 $agentnum ||= $opt{'agentnum'};
501 $self->with_classnum($opt{'classnum'}, $opt{'use_override'}),
502 $self->with_usageclass($opt{'usageclass'}),
505 if ( $opt{'distribute'} ) {
506 # then limit according to the usage time, not the billing date
507 push @where, $self->in_time_period_and_agent($speriod, $eperiod, $agentnum,
508 'cust_bill_pkg_detail.startdate'
512 push @where, $self->in_time_period_and_agent($speriod, $eperiod, $agentnum,
517 my $total_sql = " SELECT SUM(amount) ";
520 " / CASE COUNT(cust_pkg.*) WHEN 0 THEN 1 ELSE COUNT(cust_pkg.*) END "
521 if $opt{average_per_cust_pkg};
524 " FROM cust_bill_pkg_detail
525 LEFT JOIN cust_bill_pkg USING ( billpkgnum )
526 LEFT JOIN cust_bill ON cust_bill_pkg.invnum = cust_bill.invnum
527 LEFT JOIN cust_main USING ( custnum )
528 LEFT JOIN cust_pkg ON cust_bill_pkg.pkgnum = cust_pkg.pkgnum
529 LEFT JOIN part_pkg USING ( pkgpart )
530 LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart
531 WHERE ".join( ' AND ', grep $_, @where );
533 $self->scalar_sql($total_sql);
537 sub cust_bill_pkg_discount {
538 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
541 #my $comparison = '';
542 #if ( $opt{'classnum'} =~ /^(\d+)$/ ) {
544 # $comparison = "IS NULL";
546 # $comparison = "= $1";
549 # if ( $opt{'use_override'} ) {
551 # part_pkg.classnum $comparison AND pkgpart_override IS NULL OR
552 # override.classnum $comparison AND pkgpart_override IS NOT NULL
555 # $where = "part_pkg.classnum $comparison";
559 $agentnum ||= $opt{'agentnum'};
562 " SELECT COALESCE( SUM( cust_bill_pkg_discount.amount ), 0 ) ";
565 # " / CASE COUNT(cust_pkg.*) WHEN 0 THEN 1 ELSE COUNT(cust_pkg.*) END "
566 # if $opt{average_per_cust_pkg};
569 " FROM cust_bill_pkg_discount
570 LEFT JOIN cust_bill_pkg USING ( billpkgnum )
571 LEFT JOIN cust_bill USING ( invnum )
572 LEFT JOIN cust_main USING ( custnum )
573 WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum);
574 # LEFT JOIN cust_pkg_discount USING ( pkgdiscountnum )
575 # LEFT JOIN discount USING ( discountnum )
576 # LEFT JOIN cust_pkg USING ( pkgnum )
577 # LEFT JOIN part_pkg USING ( pkgpart )
578 # LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart
580 return $self->scalar_sql($total_sql);
584 sub setup_pkg { shift->pkg_field( 'setup', @_ ); }
585 sub susp_pkg { shift->pkg_field( 'susp', @_ ); }
586 sub cancel_pkg { shift->pkg_field( 'cancel', @_ ); }
589 my( $self, $field, $speriod, $eperiod, $agentnum ) = @_;
591 SELECT COUNT(*) FROM cust_pkg
592 LEFT JOIN cust_main USING ( custnum )
593 WHERE ". $self->in_time_period_and_agent( $speriod,
602 #this is going to be harder..
604 # my( $self, $speriod, $eperiod, $agentnum ) = @_;
605 # $self->scalar_sql("
606 # SELECT COUNT(*) FROM h_cust_pkg
611 sub in_time_period_and_agent {
612 my( $self, $speriod, $eperiod, $agentnum ) = splice(@_, 0, 4);
613 my $col = @_ ? shift() : '_date';
615 my $sql = "$col >= $speriod AND $col < $eperiod";
618 $sql .= " AND cust_main.agentnum = $agentnum"
621 #agent virtualization
623 $FS::CurrentUser::CurrentUser->agentnums_sql( 'table'=>'cust_main' );
629 my ( $self, %opt ) = @_;
630 return '' unless $opt{'custnum'};
631 $opt{'custnum'} =~ /^\d+$/ ? " and custnum = $opt{custnum} " : '';
636 my ($classnum, $use_override) = @_;
637 return '' unless $classnum =~ /^\d+$/;
639 if ( $classnum == 0 ) {
640 $comparison = 'IS NULL';
643 $comparison = "= $classnum";
645 if ( $use_override ) {
647 part_pkg.classnum $comparison AND pkgpart_override IS NULL OR
648 override.classnum $comparison AND pkgpart_override IS NOT NULL
652 return "part_pkg.classnum $comparison";
656 sub with_usageclass {
658 my ($classnum, $use_override) = @_;
659 return '' unless $classnum =~ /^\d+$/;
661 if ( $classnum == 0 ) {
662 $comparison = 'IS NULL';
665 $comparison = "= $classnum";
667 return "cust_bill_pkg_detail.classnum $comparison";
671 my( $self, $sql ) = ( shift, shift );
672 my $sth = dbh->prepare($sql) or die dbh->errstr;
673 warn "FS::Report::Table\n$sql\n" if $DEBUG;
675 or die "Unexpected error executing statement $sql: ". $sth->errstr;
676 $sth->fetchrow_arrayref->[0] || 0;
685 =item init_projection
687 Sets up for future projection of all observables on the report. Currently
688 this is limited to 'cust_bill_pkg'.
692 sub init_projection {
693 # this is weird special case stuff--some redesign may be needed
694 # to use it for anything else
697 if ( driver_name ne 'Pg' ) {
698 # also database-specific for now
699 die "projection reports not supported on this platform";
702 my %items = map {$_ => 1} @{ $self->{items} };
703 if ($items{'cust_bill_pkg'}) {
707 # could use TEMPORARY TABLE but we're already transaction-protected
708 'DROP TABLE IF EXISTS v_cust_bill_pkg',
709 'CREATE TABLE v_cust_bill_pkg ' .
710 '(LIKE cust_bill_pkg,
711 usage numeric(10,2), _date integer, expire integer)',
712 # XXX this should be smart enough to take only the ones with
713 # sdate/edate overlapping the ROI, for performance
714 "INSERT INTO v_cust_bill_pkg (
715 SELECT cust_bill_pkg.*,
716 (SELECT COALESCE(SUM(amount),0) FROM cust_bill_pkg_detail
717 WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum),
720 FROM cust_bill_pkg $cust_bill_pkg_join
723 foreach my $sql (@sql) {
724 warn "[init_projection] $sql\n" if $DEBUG;
725 $dbh->do($sql) or die $dbh->errstr;
730 =item extend_projection START END
732 Generates data for the next period of projection. This will be called
733 for sequential periods where the END of one equals the START of the next
738 sub extend_projection {
740 my ($speriod, $eperiod) = @_;
741 my %items = map {$_ => 1} @{ $self->{items} };
742 if ($items{'cust_bill_pkg'}) {
744 # Find all line items that end after the start of the period (and have
745 # recurring fees, and don't expire before they end). Choose the latest
746 # one for each package. If it ends before the end of the period, copy
747 # it forward by one billing period.
748 # Repeat this until the latest line item for each package no longer ends
749 # within the period. This is certain to happen in finitely many
750 # iterations as long as freq > 0.
751 # - Pg only, obviously.
752 # - Gives bad results if freq_override is used.
753 my @fields = ( FS::cust_bill_pkg->fields, qw( usage _date expire ) );
754 my $insert_fields = join(',', @fields);
755 my $add_freq = sub { # emulate FS::part_pkg::add_freq
757 "EXTRACT( EPOCH FROM TO_TIMESTAMP($field) + (CASE WHEN freq ~ E'\\\\D' ".
758 "THEN freq ELSE freq || 'mon' END)::INTERVAL) AS $field";
762 $_ = $add_freq->('edate');
764 elsif ($_ eq 'sdate') {
765 $_ = 'edate AS sdate'
767 elsif ($_ eq 'setup') {
768 $_ = '0 AS setup' #because recurring only
770 elsif ($_ eq '_date') {
771 $_ = $add_freq->('_date');
774 my $select_fields = join(',', @fields);
777 # Subquery here because we need to DISTINCT the whole set, select the
778 # latest charge per pkgnum, and _then_ check edate < $eperiod
779 # and edate < expire.
780 "INSERT INTO v_cust_bill_pkg ($insert_fields)
781 SELECT $select_fields FROM (
782 SELECT DISTINCT ON (pkgnum) * FROM v_cust_bill_pkg
783 WHERE edate >= $speriod
787 ORDER BY pkgnum, edate DESC
789 WHERE edate < $eperiod AND (edate < expire OR expire IS NULL)";
792 warn "[extend_projection] $sql\n" if $DEBUG;
793 $rows = $dbh->do($sql) or die $dbh->errstr;
794 warn "[extend_projection] $rows rows\n" if $DEBUG;
805 L<FS::Report::Table::Monthly>, reports in the web interface.