1 package FS::Report::Table;
4 use vars qw( @ISA $DEBUG );
6 use Time::Local qw( timelocal );
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 invoiced: The total amount charged on all invoices.
39 sub invoiced { #invoiced
40 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
45 LEFT JOIN cust_main USING ( custnum )
46 WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum)
47 . (%opt ? $self->for_custnum(%opt) : '')
52 =item netsales: invoiced - netcredits
56 sub netsales { #net sales
57 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
59 $self->invoiced($speriod,$eperiod,$agentnum,%opt)
60 - $self->netcredits($speriod,$eperiod,$agentnum,%opt);
63 =item cashflow: payments - refunds
68 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
70 $self->payments($speriod, $eperiod, $agentnum, %opt)
71 - $self->refunds( $speriod, $eperiod, $agentnum, %opt);
74 =item netcashflow: payments - netrefunds
79 my( $self, $speriod, $eperiod, $agentnum ) = @_;
81 $self->receipts($speriod, $eperiod, $agentnum)
82 - $self->netrefunds( $speriod, $eperiod, $agentnum);
85 =item payments: The sum of payments received in the period.
90 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
94 LEFT JOIN cust_main USING ( custnum )
95 WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum)
96 . (%opt ? $self->for_custnum(%opt) : '')
100 =item credits: The sum of credits issued in the period.
105 my( $self, $speriod, $eperiod, $agentnum ) = @_;
109 LEFT JOIN cust_main USING ( custnum )
110 WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum)
114 =item refunds: The sum of refunds paid 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 netcredits: The sum of credit applications to invoices in the period.
134 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
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,
145 . (%opt ? $self->for_custnum(%opt) : '')
149 =item receipts: The sum of payment applications to invoices in the period.
153 sub receipts { #net payments
154 my( $self, $speriod, $eperiod, $agentnum ) = @_;
156 SELECT SUM(cust_bill_pay.amount)
158 LEFT JOIN cust_bill USING ( invnum )
159 LEFT JOIN cust_main USING ( custnum )
160 WHERE ". $self->in_time_period_and_agent( $speriod,
168 =item netrefunds: The sum of refund applications to credits in the period.
173 my( $self, $speriod, $eperiod, $agentnum ) = @_;
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,
189 #these should be auto-generated or $AUTOLOADed or something
191 my( $self, $speriod, $eperiod, $agentnum ) = @_;
192 $speriod = $self->_subtract_11mo($speriod);
193 $self->invoiced($speriod, $eperiod, $agentnum);
197 my( $self, $speriod, $eperiod, $agentnum ) = @_;
198 $speriod = $self->_subtract_11mo($speriod);
199 $self->netsales($speriod, $eperiod, $agentnum);
203 my( $self, $speriod, $eperiod, $agentnum ) = @_;
204 $speriod = $self->_subtract_11mo($speriod);
205 $self->receipts($speriod, $eperiod, $agentnum);
209 my( $self, $speriod, $eperiod, $agentnum ) = @_;
210 $speriod = $self->_subtract_11mo($speriod);
211 $self->payments($speriod, $eperiod, $agentnum);
215 my( $self, $speriod, $eperiod, $agentnum ) = @_;
216 $speriod = $self->_subtract_11mo($speriod);
217 $self->credits($speriod, $eperiod, $agentnum);
220 sub netcredits_12mo {
221 my( $self, $speriod, $eperiod, $agentnum ) = @_;
222 $speriod = $self->_subtract_11mo($speriod);
223 $self->netcredits($speriod, $eperiod, $agentnum);
227 my( $self, $speriod, $eperiod, $agentnum ) = @_;
228 $speriod = $self->_subtract_11mo($speriod);
229 $self->cashflow($speriod, $eperiod, $agentnum);
232 sub netcashflow_12mo {
233 my( $self, $speriod, $eperiod, $agentnum ) = @_;
234 $speriod = $self->_subtract_11mo($speriod);
235 $self->cashflow($speriod, $eperiod, $agentnum);
239 my( $self, $speriod, $eperiod, $agentnum ) = @_;
240 $speriod = $self->_subtract_11mo($speriod);
241 $self->refunds($speriod, $eperiod, $agentnum);
244 sub netrefunds_12mo {
245 my( $self, $speriod, $eperiod, $agentnum ) = @_;
246 $speriod = $self->_subtract_11mo($speriod);
247 $self->netrefunds($speriod, $eperiod, $agentnum);
251 #not being too bad with the false laziness
253 my($self, $time) = @_;
254 my ($sec,$min,$hour,$mday,$mon,$year) = (localtime($time) )[0,1,2,3,4,5];
256 if ( $mon < 0 ) { $mon+=12; $year--; }
257 timelocal($sec,$min,$hour,$mday,$mon,$year);
260 =item cust_pkg_setup_cost: The total setup costs of packages setup in the period
262 'classnum': limit to this package class.
266 sub cust_pkg_setup_cost {
267 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
270 if ( $opt{'classnum'} =~ /^(\d+)$/ ) {
272 $comparison = 'IS NULL';
275 $comparison = "= $1";
277 $where = "AND part_pkg.classnum $comparison";
279 $agentnum ||= $opt{'agentnum'};
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 )
287 AND ".$self->in_time_period_and_agent(
288 $speriod, $eperiod, $agentnum, 'cust_pkg.setup');
289 return $self->scalar_sql($total_sql);
292 =item cust_pkg_recur_cust: the total recur costs of packages in the period
294 'classnum': limit to this package class.
298 sub cust_pkg_recur_cost {
299 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
302 if ( $opt{'classnum'} =~ /^(\d+)$/ ) {
304 $comparison = 'IS NULL';
307 $comparison = "= $1";
309 $where = " AND part_pkg.classnum $comparison";
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;
316 $FS::CurrentUser::CurrentUser->agentnums_sql('table' => 'cust_main');
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 )
324 AND cust_pkg.setup < $eperiod
325 AND (cust_pkg.cancel > $speriod OR cust_pkg.cancel IS NULL)
327 return $self->scalar_sql($total_sql);
330 =item cust_bill_pkg: the total package charges on invoice line items.
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.
336 'classnum': limit to this package class.
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.
341 'freq': limit to packages with this frequency. Currently uses the part_pkg
342 frequency, so term discounted packages may give odd results.
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.
354 my( $speriod, $eperiod, $agentnum, %opt ) = @_;
356 my %charges = map {$_=>1} split('', $opt{'charges'} || 'SRU');
359 $sum += $self->cust_bill_pkg_setup(@_) if $charges{S};
360 $sum += $self->cust_bill_pkg_recur(@_) if $charges{R};
361 $sum += $self->cust_bill_pkg_detail(@_) if $charges{U};
365 my $cust_bill_pkg_from =
367 LEFT JOIN cust_bill USING ( invnum )
368 LEFT JOIN cust_main USING ( custnum )
369 LEFT JOIN cust_pkg USING ( pkgnum )
370 LEFT JOIN part_pkg USING ( pkgpart )
371 LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart';
373 sub cust_bill_pkg_setup {
375 my ($speriod, $eperiod, $agentnum, %opt) = @_;
377 $agentnum ||= $opt{'agentnum'};
381 $self->with_classnum($opt{'classnum'}, $opt{'use_override'}),
382 $self->in_time_period_and_agent($speriod, $eperiod, $agentnum),
385 my $total_sql = "SELECT COALESCE(SUM(cust_bill_pkg.setup),0)
386 FROM $cust_bill_pkg_from
387 WHERE " . join(' AND ', grep $_, @where);
389 $self->scalar_sql($total_sql);
392 sub cust_bill_pkg_recur {
394 my ($speriod, $eperiod, $agentnum, %opt) = @_;
396 $agentnum ||= $opt{'agentnum'};
400 $self->with_classnum($opt{'classnum'}, $opt{'use_override'}),
403 # subtract all usage from the line item regardless of date
404 my $item_usage = '( SELECT COALESCE(SUM(cust_bill_pkg_detail.amount),0)
405 FROM cust_bill_pkg_detail
406 WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum )';
407 my $recur_fraction = '';
409 if ( $opt{'distribute'} ) {
410 push @where, "cust_main.agentnum = $agentnum" if $agentnum;
412 "cust_bill_pkg.sdate < $eperiod",
413 "cust_bill_pkg.edate > $speriod",
415 # the fraction of edate - sdate that's within [speriod, eperiod]
416 $recur_fraction = " *
417 CAST(LEAST($eperiod, cust_bill_pkg.edate) -
418 GREATEST($speriod, cust_bill_pkg.sdate) AS DECIMAL) /
419 (cust_bill_pkg.edate - cust_bill_pkg.sdate)";
423 $self->in_time_period_and_agent($speriod, $eperiod, $agentnum);
426 my $total_sql = "SELECT COALESCE(SUM(
427 (cust_bill_pkg.recur - $item_usage) $recur_fraction),0)
428 FROM $cust_bill_pkg_from
429 WHERE ".join(' AND ', grep $_, @where);
431 $self->scalar_sql($total_sql);
434 =item cust_bill_pkg_detail: the total usage charges in detail lines.
436 Arguments as for C<cust_bill_pkg>, plus:
438 'usageclass': limit to this usage class number.
442 sub cust_bill_pkg_detail {
443 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
445 my @where = ( "cust_bill_pkg.pkgnum != 0" );
447 $agentnum ||= $opt{'agentnum'};
450 $self->with_classnum($opt{'classnum'}, $opt{'use_override'}),
451 $self->with_usageclass($opt{'usageclass'}),
454 if ( $opt{'distribute'} ) {
455 # then limit according to the usage time, not the billing date
456 push @where, $self->in_time_period_and_agent($speriod, $eperiod, $agentnum,
457 'cust_bill_pkg_detail.startdate'
461 push @where, $self->in_time_period_and_agent($speriod, $eperiod, $agentnum,
466 my $total_sql = " SELECT SUM(amount) ";
469 " / CASE COUNT(cust_pkg.*) WHEN 0 THEN 1 ELSE COUNT(cust_pkg.*) END "
470 if $opt{average_per_cust_pkg};
473 " FROM cust_bill_pkg_detail
474 LEFT JOIN cust_bill_pkg USING ( billpkgnum )
475 LEFT JOIN cust_bill ON cust_bill_pkg.invnum = cust_bill.invnum
476 LEFT JOIN cust_main USING ( custnum )
477 LEFT JOIN cust_pkg ON cust_bill_pkg.pkgnum = cust_pkg.pkgnum
478 LEFT JOIN part_pkg USING ( pkgpart )
479 LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart
480 WHERE ".join( ' AND ', grep $_, @where );
482 $self->scalar_sql($total_sql);
486 sub cust_bill_pkg_discount {
487 my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
490 #my $comparison = '';
491 #if ( $opt{'classnum'} =~ /^(\d+)$/ ) {
493 # $comparison = "IS NULL";
495 # $comparison = "= $1";
498 # if ( $opt{'use_override'} ) {
500 # part_pkg.classnum $comparison AND pkgpart_override IS NULL OR
501 # override.classnum $comparison AND pkgpart_override IS NOT NULL
504 # $where = "part_pkg.classnum $comparison";
508 $agentnum ||= $opt{'agentnum'};
511 " SELECT COALESCE( SUM( cust_bill_pkg_discount.amount ), 0 ) ";
514 # " / CASE COUNT(cust_pkg.*) WHEN 0 THEN 1 ELSE COUNT(cust_pkg.*) END "
515 # if $opt{average_per_cust_pkg};
518 " FROM cust_bill_pkg_discount
519 LEFT JOIN cust_bill_pkg USING ( billpkgnum )
520 LEFT JOIN cust_bill USING ( invnum )
521 LEFT JOIN cust_main USING ( custnum )
522 WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum);
523 # LEFT JOIN cust_pkg_discount USING ( pkgdiscountnum )
524 # LEFT JOIN discount USING ( discountnum )
525 # LEFT JOIN cust_pkg USING ( pkgnum )
526 # LEFT JOIN part_pkg USING ( pkgpart )
527 # LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart
529 return $self->scalar_sql($total_sql);
533 sub setup_pkg { shift->pkg_field( @_, 'setup' ); }
534 sub susp_pkg { shift->pkg_field( @_, 'susp' ); }
535 sub cancel_pkg { shift->pkg_field( @_, 'cancel'); }
538 my( $self, $speriod, $eperiod, $agentnum, $field ) = @_;
540 SELECT COUNT(*) FROM cust_pkg
541 LEFT JOIN cust_main USING ( custnum )
542 WHERE ". $self->in_time_period_and_agent( $speriod,
551 #this is going to be harder..
553 # my( $self, $speriod, $eperiod, $agentnum ) = @_;
554 # $self->scalar_sql("
555 # SELECT COUNT(*) FROM h_cust_pkg
560 sub in_time_period_and_agent {
561 my( $self, $speriod, $eperiod, $agentnum ) = splice(@_, 0, 4);
562 my $col = @_ ? shift() : '_date';
564 my $sql = "$col >= $speriod AND $col < $eperiod";
567 $sql .= " AND cust_main.agentnum = $agentnum"
570 #agent virtualization
572 $FS::CurrentUser::CurrentUser->agentnums_sql( 'table'=>'cust_main' );
578 my ( $self, %opt ) = @_;
579 return '' unless $opt{'custnum'};
580 $opt{'custnum'} =~ /^\d+$/ ? " and custnum = $opt{custnum} " : '';
585 my ($classnum, $use_override) = @_;
586 return '' unless $classnum =~ /^\d+$/;
588 if ( $classnum == 0 ) {
589 $comparison = 'IS NULL';
592 $comparison = "= $classnum";
594 if ( $use_override ) {
596 part_pkg.classnum $comparison AND pkgpart_override IS NULL OR
597 override.classnum $comparison AND pkgpart_override IS NOT NULL
601 return "part_pkg.classnum $comparison";
605 sub with_usageclass {
607 my ($classnum, $use_override) = @_;
608 return '' unless $classnum =~ /^\d+$/;
610 if ( $classnum == 0 ) {
611 $comparison = 'IS NULL';
614 $comparison = "= $classnum";
616 return "cust_bill_pkg_detail.classnum $comparison";
620 my( $self, $sql ) = ( shift, shift );
621 my $sth = dbh->prepare($sql) or die dbh->errstr;
622 warn "FS::Report::Table\n$sql\n" if $DEBUG;
624 or die "Unexpected error executing statement $sql: ". $sth->errstr;
625 $sth->fetchrow_arrayref->[0] || 0;
636 L<FS::Report::Table::Monthly>, reports in the web interface.