summaryrefslogtreecommitdiff
path: root/FS
diff options
context:
space:
mode:
authorjeff <jeff>2009-11-20 17:33:40 +0000
committerjeff <jeff>2009-11-20 17:33:40 +0000
commit62bfeae993beb7f98d50d319360f1fece128982b (patch)
tree6c97d81c0983b672dfeb15aeaf00f8be58cf4dec /FS
parent457cf05ffc31212de613249c95a8ab05aed34f47 (diff)
invoice formatting: add sections for usage, add sections per svc_phone, add folding like line items into one #6592
Diffstat (limited to 'FS')
-rw-r--r--FS/FS/Conf.pm14
-rw-r--r--FS/FS/Schema.pm11
-rw-r--r--FS/FS/cust_bill.pm553
-rw-r--r--FS/FS/cust_bill_pkg.pm21
-rw-r--r--FS/FS/cust_bill_pkg_detail.pm101
-rw-r--r--FS/FS/part_pkg/voip_cdr.pm29
-rw-r--r--FS/FS/usage_class.pm265
7 files changed, 974 insertions, 20 deletions
diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm
index 3df23263d..d186d04c2 100644
--- a/FS/FS/Conf.pm
+++ b/FS/FS/Conf.pm
@@ -1036,6 +1036,20 @@ worry that config_items is freeside-specific and icky.
'type' => 'checkbox',
},
+ {
+ 'key' => 'usage_class_as_a_section',
+ 'section' => 'billing',
+ 'description' => 'Split usage into sections and label according to usage class name when enabled. Only valid when invoice_sections is enabled.',
+ 'type' => 'checkbox',
+ },
+
+ {
+ 'key' => 'svc_phone_sections',
+ 'section' => 'billing',
+ 'description' => 'Create a section for each svc_phone when enabled. Only valid when invoice_sections is enabled.',
+ 'type' => 'checkbox',
+ },
+
{
'key' => 'finance_pkgclass',
'section' => 'billing',
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index f4cbe1d30..553ddc2a8 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -573,7 +573,9 @@ sub tables_hashref {
'amount', 'decimal', 'NULL', '10,4', '', '',
'format', 'char', 'NULL', 1, '', '',
'classnum', 'int', 'NULL', '', '', '',
+ 'duration', 'int', 'NULL', '', 0, '',
'phonenum', 'varchar', 'NULL', 15, '', '',
+ 'regionname', 'varchar', 'NULL', $char_d, '', '',
'detail', 'varchar', '', 255, '', '',
],
'primary_key' => 'detailnum',
@@ -2010,9 +2012,11 @@ sub tables_hashref {
'usage_class' => {
'columns' => [
- 'classnum', 'serial', '', '', '', '',
- 'classname', 'varchar', '', $char_d, '', '',
- 'disabled', 'char', 'NULL', 1, '', '',
+ 'classnum', 'serial', '', '', '', '',
+ 'weight', 'int', 'NULL', '', '', '',
+ 'classname', 'varchar', '', $char_d, '', '',
+ 'format', 'varchar', 'NULL', $char_d, '', '',
+ 'disabled', 'char', 'NULL', 1, '', '',
],
'primary_key' => 'classnum',
'unique' => [],
@@ -2125,6 +2129,7 @@ sub tables_hashref {
'categorynum', 'serial', '', '', '', '',
'categoryname', 'varchar', '', $char_d, '', '',
'weight', 'int', 'NULL', '', '', '',
+ 'condense', 'char', 'NULL', 1, '', '',
'disabled', 'char', 'NULL', 1, '', '',
],
'primary_key' => 'categorynum',
diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm
index 82fa78a4c..cf339a8c7 100644
--- a/FS/FS/cust_bill.pm
+++ b/FS/FS/cust_bill.pm
@@ -11,6 +11,7 @@ use File::Temp 0.14;
use String::ShellQuote;
use HTML::Entities;
use Locale::Country;
+use Storable qw( freeze thaw );
use FS::UID qw( datasrc );
use FS::Misc qw( send_email send_fax generate_ps generate_pdf do_print );
use FS::Record qw( qsearch qsearchs dbh );
@@ -19,6 +20,7 @@ use FS::cust_main;
use FS::cust_statement;
use FS::cust_bill_pkg;
use FS::cust_bill_pkg_display;
+use FS::cust_bill_pkg_detail;
use FS::cust_credit;
use FS::cust_pay;
use FS::cust_pkg;
@@ -2474,8 +2476,24 @@ sub print_generic {
my $multisection = $conf->exists('invoice_sections', $cust_main->agentnum);
my $late_sections = [];
if ( $multisection ) {
+ my ($extra_sections, $extra_lines) =
+ $self->_items_extra_usage_sections($escape_function, $format)
+ if $conf->exists('usage_class_as_a_section', $cust_main->agentnum);
+
+ push @detail_items, @$extra_lines if $extra_lines;
push @sections,
- $self->_items_sections( $late_sections, $summarypage, $escape_function );
+ $self->_items_sections( $late_sections, # this could stand a refactor
+ $summarypage,
+ $escape_function,
+ $extra_sections,
+ $format, #bah
+ );
+ if ($conf->exists('svc_phone_sections')) {
+ my ($phone_sections, $phone_lines) =
+ $self->_items_svc_phone_sections($escape_function, $format);
+ push @{$late_sections}, @$phone_sections;
+ push @detail_items, @$phone_lines;
+ }
}else{
push @sections, { 'description' => '', 'subtotal' => '' };
}
@@ -2528,6 +2546,11 @@ sub print_generic {
sprintf('%.2f', $section->{'subtotal'})
if $multisection;
+ # begin some normalization
+ $section->{'amount'} = $section->{'subtotal'}
+ if $multisection;
+
+
if ( $section->{'description'} ) {
push @buf, ( [ &$escape_function($section->{'description'}), '' ],
[ '', '' ],
@@ -3115,6 +3138,8 @@ sub _items_sections {
my $late = shift;
my $summarypage = shift;
my $escape = shift;
+ my $extra_sections = shift;
+ my $format = shift;
my %subtotal = ();
my %late_subtotal = ();
@@ -3188,8 +3213,13 @@ sub _items_sections {
push @$late, map { { 'description' => &{$escape}($_),
'subtotal' => $late_subtotal{$_},
'post_total' => 1,
+ 'sort_weight' => _pkg_category($_)->weight,
+ (_pkg_category($_)->condense
+ ? $self->_condense_section($format)
+ : ()
+ ),
} }
- sort _categorysort keys %late_subtotal;
+ sort _sectionsort keys %late_subtotal;
my @sections;
if ( $summarypage ) {
@@ -3199,19 +3229,26 @@ sub _items_sections {
@sections = keys %subtotal;
}
- map { { 'description' => &{$escape}($_),
- 'subtotal' => $subtotal{$_},
- 'summarized' => $not_tax{$_} ? '' : 'Y',
- 'tax_section' => $not_tax{$_} ? '' : 'Y',
- }
- }
- sort _categorysort @sections;
+ my @early = map { { 'description' => &{$escape}($_),
+ 'subtotal' => $subtotal{$_},
+ 'summarized' => $not_tax{$_} ? '' : 'Y',
+ 'tax_section' => $not_tax{$_} ? '' : 'Y',
+ 'sort_weight' => _pkg_category($_)->weight,
+ (_pkg_category($_)->condense
+ ? $self->_condense_section($format)
+ : ()
+ ),
+ }
+ } @sections;
+ push @early, @$extra_sections if $extra_sections;
+
+ sort { $a->{sort_weight} <=> $b->{sort_weight} } @early;
}
#helper subs for above
-sub _categorysort {
+sub _sectionsort {
_pkg_category($a)->weight <=> _pkg_category($b)->weight;
}
@@ -3221,6 +3258,478 @@ sub _pkg_category {
qsearchs( 'pkg_category', { 'categoryname' => $categoryname } );
}
+my %condensed_format = (
+ 'label' => [ qw( Description Qty Amount ) ],
+ 'fields' => [
+ sub { shift->{description} },
+ sub { shift->{quantity} },
+ sub { shift->{amount} },
+ ],
+ 'align' => [ qw( l r r ) ],
+ 'span' => [ qw( 5 1 1 ) ], # unitprices?
+ 'width' => [ qw( 10.7cm 1.4cm 1.6cm ) ], # don't like this
+);
+
+sub _condense_section {
+ my ( $self, $format ) = ( shift, shift );
+ ( 'condensed' => 1,
+ map { my $method = "_condensed_$_"; $_ => $self->$method($format) }
+ qw( description_generator
+ header_generator
+ total_generator
+ total_line_generator
+ )
+ );
+}
+
+sub _condensed_generator_defaults {
+ my ( $self, $format ) = ( shift, shift );
+ return ( \%condensed_format, ' ', ' ', ' ', sub { shift } );
+}
+
+my %html_align = (
+ 'c' => 'center',
+ 'l' => 'left',
+ 'r' => 'right',
+);
+
+sub _condensed_header_generator {
+ my ( $self, $format ) = ( shift, shift );
+
+ my ( $f, $prefix, $suffix, $separator, $column ) =
+ _condensed_generator_defaults($format);
+
+ if ($format eq 'latex') {
+ $prefix = "\\hline\n\\rule{0pt}{2.5ex}\n\\makebox[1.4cm]{}&\n";
+ $suffix = "\\\\\n\\hline";
+ $separator = "&\n";
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
+ };
+ } elsif ( $format eq 'html' ) {
+ $prefix = '<th></th>';
+ $suffix = '';
+ $separator = '';
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return qq!<th align="$html_align{$a}">$d</th>!;
+ };
+ }
+
+ sub {
+ my @args = @_;
+ my @result = ();
+
+ foreach (my $i = 0; $f->{label}->[$i]; $i++) {
+ push @result,
+ &{$column}( map { $f->{$_}->[$i] } qw(label align span width) );
+ }
+
+ $prefix. join($separator, @result). $suffix;
+ };
+
+}
+
+sub _condensed_description_generator {
+ my ( $self, $format ) = ( shift, shift );
+
+ my ( $f, $prefix, $suffix, $separator, $column ) =
+ _condensed_generator_defaults($format);
+
+ if ($format eq 'latex') {
+ $prefix = "\\hline\n\\multicolumn{1}{c}{\\rule{0pt}{2.5ex}~} &\n";
+ $suffix = '\\\\';
+ $separator = " & \n";
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
+ };
+ }elsif ( $format eq 'html' ) {
+ $prefix = '"><td align="center"></td>';
+ $suffix = '';
+ $separator = '';
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return qq!<td align="$html_align{$a}">$d</td>!;
+ };
+ }
+
+ sub {
+ my @args = @_;
+ my @result = ();
+
+ foreach (my $i = 0; $f->{label}->[$i]; $i++) {
+ push @result, &{$column}( &{$f->{fields}->[$i]}(@args),
+ map { $f->{$_}->[$i] } qw(align span width)
+ );
+ }
+
+ $prefix. join( $separator, @result ). $suffix;
+ };
+
+}
+
+sub _condensed_total_generator {
+ my ( $self, $format ) = ( shift, shift );
+
+ my ( $f, $prefix, $suffix, $separator, $column ) =
+ _condensed_generator_defaults($format);
+ my $style = '';
+
+ if ($format eq 'latex') {
+ $prefix = "& ";
+ $suffix = "\\\\\n";
+ $separator = " & \n";
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
+ };
+ }elsif ( $format eq 'html' ) {
+ $prefix = '';
+ $suffix = '';
+ $separator = '';
+ $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
+ };
+ }
+
+
+ sub {
+ my @args = @_;
+ my @result = ();
+
+ # my $r = &{$f->{fields}->[$i]}(@args);
+ # $r .= ' Total' unless $i;
+
+ foreach (my $i = 0; $f->{label}->[$i]; $i++) {
+ push @result,
+ &{$column}( &{$f->{fields}->[$i]}(@args). ($i ? '' : ' Total'),
+ map { $f->{$_}->[$i] } qw(align span width)
+ );
+ }
+
+ $prefix. join( $separator, @result ). $suffix;
+ };
+
+}
+
+=item total_line_generator FORMAT
+
+Returns a coderef used for generation of invoice total line items for this
+usage_class. FORMAT is either html or latex
+
+=cut
+
+# should not be used: will have issues with hash element names (description vs
+# total_item and amount vs total_amount -- another array of functions?
+
+sub _condensed_total_line_generator {
+ my ( $self, $format ) = ( shift, shift );
+
+ my ( $f, $prefix, $suffix, $separator, $column ) =
+ _condensed_generator_defaults($format);
+ my $style = '';
+
+ if ($format eq 'latex') {
+ $prefix = "& ";
+ $suffix = "\\\\\n";
+ $separator = " & \n";
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
+ };
+ }elsif ( $format eq 'html' ) {
+ $prefix = '';
+ $suffix = '';
+ $separator = '';
+ $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
+ };
+ }
+
+
+ sub {
+ my @args = @_;
+ my @result = ();
+
+ foreach (my $i = 0; $f->{label}->[$i]; $i++) {
+ push @result,
+ &{$column}( &{$f->{fields}->[$i]}(@args),
+ map { $f->{$_}->[$i] } qw(align span width)
+ );
+ }
+
+ $prefix. join( $separator, @result ). $suffix;
+ };
+
+}
+
+#sub _items_extra_usage_sections {
+# my $self = shift;
+# my $escape = shift;
+#
+# my %sections = ();
+#
+# my %usage_class = map{ $_->classname, $_ } qsearch('usage_class', {});
+# foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
+# {
+# next unless $cust_bill_pkg->pkgnum > 0;
+#
+# foreach my $section ( keys %usage_class ) {
+#
+# my $usage = $cust_bill_pkg->usage($section);
+#
+# next unless $usage && $usage > 0;
+#
+# $sections{$section} ||= 0;
+# $sections{$section} += $usage;
+#
+# }
+#
+# }
+#
+# map { { 'description' => &{$escape}($_),
+# 'subtotal' => $sections{$_},
+# 'summarized' => '',
+# 'tax_section' => '',
+# }
+# }
+# sort {$usage_class{$a}->weight <=> $usage_class{$b}->weight} keys %sections;
+#
+#}
+
+sub _items_extra_usage_sections {
+ my $self = shift;
+ my $escape = shift;
+ my $format = shift;
+
+ my %sections = ();
+ my %classnums = ();
+ my %lines = ();
+
+ my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} );
+ foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+ next unless $cust_bill_pkg->pkgnum > 0;
+
+ foreach my $classnum ( keys %usage_class ) {
+ my $section = $usage_class{$classnum}->classname;
+ $classnums{$section} = $classnum;
+
+ foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail($classnum) ) {
+ my $amount = $detail->amount;
+ next unless $amount && $amount > 0;
+
+ $sections{$section} ||= { 'subtotal'=>0, 'calls'=>0, 'duration'=>0 };
+ $sections{$section}{amount} += $amount; #subtotal
+ $sections{$section}{calls}++;
+ $sections{$section}{duration} += $detail->duration;
+
+ my $desc = $detail->regionname;
+ my $description = $desc;
+ $description = substr($desc, 0, 50). '...'
+ if $format eq 'latex' && length($desc) > 50;
+
+ $lines{$section}{$desc} ||= {
+ description => &{$escape}($description),
+ #pkgpart => $part_pkg->pkgpart,
+ pkgnum => $cust_bill_pkg->pkgnum,
+ ref => '',
+ amount => 0,
+ calls => 0,
+ duration => 0,
+ #unit_amount => $cust_bill_pkg->unitrecur,
+ quantity => $cust_bill_pkg->quantity,
+ product_code => 'N/A',
+ ext_description => [],
+ };
+
+ $lines{$section}{$desc}{amount} += $amount;
+ $lines{$section}{$desc}{calls}++;
+ $lines{$section}{$desc}{duration} += $detail->duration;
+
+ }
+ }
+ }
+
+ my %sectionmap = ();
+ foreach (keys %sections) {
+ my $usage_class = $usage_class{$classnums{$_}};
+ $sectionmap{$_} = { 'description' => &{$escape}($_),
+ 'amount' => $sections{$_}{amount}, #subtotal
+ 'calls' => $sections{$_}{calls},
+ 'duration' => $sections{$_}{duration},
+ 'summarized' => '',
+ 'tax_section' => '',
+ 'sort_weight' => $usage_class->weight,
+ ( $usage_class->format
+ ? ( map { $_ => $usage_class->$_($format) }
+ qw( description_generator header_generator total_generator total_line_generator )
+ )
+ : ()
+ ),
+ };
+ }
+
+ my @sections = sort { $a->{sort_weight} <=> $b->{sort_weight} }
+ values %sectionmap;
+
+ my @lines = ();
+ foreach my $section ( keys %lines ) {
+ foreach my $line ( keys %{$lines{$section}} ) {
+ my $l = $lines{$section}{$line};
+ $l->{section} = $sectionmap{$section};
+ $l->{amount} = sprintf( "%.2f", $l->{amount} );
+ #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} );
+ push @lines, $l;
+ }
+ }
+
+ return(\@sections, \@lines);
+
+}
+
+sub _items_svc_phone_sections {
+ my $self = shift;
+ my $escape = shift;
+ my $format = shift;
+
+ my %sections = ();
+ my %classnums = ();
+ my %lines = ();
+
+ my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} );
+
+ foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+ next unless $cust_bill_pkg->pkgnum > 0;
+
+ foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail ) {
+
+ my $phonenum = $detail->phonenum;
+ next unless $phonenum;
+
+ my $amount = $detail->amount;
+ next unless $amount && $amount > 0;
+
+ $sections{$phonenum} ||= { 'amount' => 0,
+ 'calls' => 0,
+ 'duration' => 0,
+ 'sort_weight' => -1,
+ 'phonenum' => $phonenum,
+ };
+ $sections{$phonenum}{amount} += $amount; #subtotal
+ $sections{$phonenum}{calls}++;
+ $sections{$phonenum}{duration} += $detail->duration;
+
+ my $desc = $detail->regionname;
+ my $description = $desc;
+ $description = substr($desc, 0, 50). '...'
+ if $format eq 'latex' && length($desc) > 50;
+
+ $lines{$phonenum}{$desc} ||= {
+ description => &{$escape}($description),
+ #pkgpart => $part_pkg->pkgpart,
+ pkgnum => '',
+ ref => '',
+ amount => 0,
+ calls => 0,
+ duration => 0,
+ #unit_amount => '',
+ quantity => '',
+ product_code => 'N/A',
+ ext_description => [],
+ };
+
+ $lines{$phonenum}{$desc}{amount} += $amount;
+ $lines{$phonenum}{$desc}{calls}++;
+ $lines{$phonenum}{$desc}{duration} += $detail->duration;
+
+ my $line = $usage_class{$detail->classnum}->classname;
+ $sections{"$phonenum $line"} ||=
+ { 'amount' => 0,
+ 'calls' => 0,
+ 'duration' => 0,
+ 'sort_weight' => $usage_class{$detail->classnum}->weight,
+ 'phonenum' => $phonenum,
+ };
+ $sections{"$phonenum $line"}{amount} += $amount; #subtotal
+ $sections{"$phonenum $line"}{calls}++;
+ $sections{"$phonenum $line"}{duration} += $detail->duration;
+
+ $lines{"$phonenum $line"}{$desc} ||= {
+ description => &{$escape}($description),
+ #pkgpart => $part_pkg->pkgpart,
+ pkgnum => '',
+ ref => '',
+ amount => 0,
+ calls => 0,
+ duration => 0,
+ #unit_amount => '',
+ quantity => '',
+ product_code => 'N/A',
+ ext_description => [],
+ };
+
+ $lines{"$phonenum $line"}{$desc}{amount} += $amount;
+ $lines{"$phonenum $line"}{$desc}{calls}++;
+ $lines{"$phonenum $line"}{$desc}{duration} += $detail->duration;
+ push @{$lines{"$phonenum $line"}{$desc}{ext_description}},
+ $detail->formatted('format' => $format);
+
+ }
+ }
+
+ my %sectionmap = ();
+ my $simple = new FS::usage_class { format => 'simple' }; #bleh
+ my $minimal = new FS::usage_class { format => 'minimal' }; #bleh
+ foreach ( keys %sections ) {
+ my $summary = $sections{$_}{sort_weight} < 0 ? 1 : 0;
+ my $usage_class = $summary ? $simple : $minimal;
+ my $ending = $summary ? ' usage charges' : '';
+ $sectionmap{$_} = { 'description' => &{$escape}($_. $ending),
+ 'amount' => $sections{$_}{amount}, #subtotal
+ 'calls' => $sections{$_}{calls},
+ 'duration' => $sections{$_}{duration},
+ 'summarized' => '',
+ 'tax_section' => '',
+ 'phonenum' => $sections{$_}{phonenum},
+ 'sort_weight' => $sections{$_}{sort_weight},
+ (
+ ( map { $_ => $usage_class->$_($format) }
+ qw( description_generator
+ header_generator
+ total_generator
+ total_line_generator
+ )
+ )
+ ),
+ };
+ }
+
+ my @sections = sort { $a->{phonenum} cmp $b->{phonenum} ||
+ $a->{sort_weight} <=> $b->{sort_weight}
+ }
+ values %sectionmap;
+
+ my @lines = ();
+ foreach my $section ( keys %lines ) {
+ foreach my $line ( keys %{$lines{$section}} ) {
+ my $l = $lines{$section}{$line};
+ $l->{section} = $sectionmap{$section};
+ $l->{amount} = sprintf( "%.2f", $l->{amount} );
+ #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} );
+ push @lines, $l;
+ }
+ }
+
+ return(\@sections, \@lines);
+
+}
+
sub _items {
my $self = shift;
@@ -3270,8 +3779,30 @@ sub _items_previous {
sub _items_pkg {
my $self = shift;
+ my %options = @_;
my @cust_bill_pkg = grep { $_->pkgnum } $self->cust_bill_pkg;
- $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
+ my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
+ if ($options{section} && $options{section}->{condensed}) {
+ my %itemshash = ();
+ local $Storable::canonical = 1;
+ foreach ( @items ) {
+ my $item = { %$_ };
+ delete $item->{ref};
+ delete $item->{ext_description};
+ my $key = freeze($item);
+ $itemshash{$key} ||= 0;
+ $itemshash{$key} ++; # += $item->{quantity};
+ }
+ @items = sort { $a->{description} cmp $b->{description} }
+ map { my $i = thaw($_);
+ $i->{quantity} = $itemshash{$_};
+ $i->{amount} =
+ sprintf( "%.2f", $i->{quantity} * $i->{amount} );#unit_amount
+ $i;
+ }
+ keys %itemshash;
+ }
+ @items;
}
sub _taxsort {
diff --git a/FS/FS/cust_bill_pkg.pm b/FS/FS/cust_bill_pkg.pm
index 094313f89..7d5094ced 100644
--- a/FS/FS/cust_bill_pkg.pm
+++ b/FS/FS/cust_bill_pkg.pm
@@ -152,6 +152,8 @@ sub insert {
'amount' => (ref($detail) ? $detail->[2] : '' ),
'classnum' => (ref($detail) ? $detail->[3] : '' ),
'phonenum' => (ref($detail) ? $detail->[4] : '' ),
+ 'duration' => (ref($detail) ? $detail->[5] : '' ),
+ 'regionname' => (ref($detail) ? $detail->[6] : '' ),
};
$error = $cust_bill_pkg_detail->insert;
if ( $error ) {
@@ -800,6 +802,25 @@ sub cust_bill_pkg_tax_Xlocation {
}
+=item cust_bill_pkg_detail [ CLASSNUM ]
+
+Returns the list of associated cust_bill_pkg_detail objects
+The optional CLASSNUM argument will limit the details to the specified usage
+class.
+
+=cut
+
+sub cust_bill_pkg_detail {
+ my $self = shift;
+ my $classnum = shift || '';
+
+ my %hash = ( 'billpkgnum' => $self->billpkgnum );
+ $hash{classnum} = $classnum if $classnum;
+
+ qsearch ( 'cust_bill_pkg_detail', { %hash } ),
+
+}
+
=back
=head1 BUGS
diff --git a/FS/FS/cust_bill_pkg_detail.pm b/FS/FS/cust_bill_pkg_detail.pm
index f8c9d1a3d..63d0ac5ba 100644
--- a/FS/FS/cust_bill_pkg_detail.pm
+++ b/FS/FS/cust_bill_pkg_detail.pm
@@ -2,6 +2,7 @@ package FS::cust_bill_pkg_detail;
use strict;
use vars qw( @ISA $me $DEBUG %GetInfoType );
+use HTML::Entities;
use FS::Record qw( qsearch qsearchs dbdef dbh );
use FS::cust_bill_pkg;
use FS::Conf;
@@ -41,6 +42,18 @@ inherits from FS::Record. The following fields are currently supported:
=item billpkgnum - link to cust_bill_pkg
+=item amount - price of this line item detail
+
+=item format - '' for straight text and 'C' for CSV in detail
+
+=item classnum - link to usage_class
+
+=item duration - granularized number of seconds for this call
+
+=item regionname -
+
+=item phonenum -
+
=item detail - detail description
=back
@@ -121,6 +134,8 @@ sub check {
#|| $self->ut_moneyn('amount')
|| $self->ut_floatn('amount')
|| $self->ut_enum('format', [ '', 'C' ] )
+ || $self->ut_numbern('duration')
+ || $self->ut_textn('regionname')
|| $self->ut_text('detail')
|| $self->ut_foreign_keyn('classnum', 'usage_class', 'classnum')
|| $self->$phonenum_check_method('phonenum')
@@ -129,6 +144,92 @@ sub check {
}
+=item formatted [ OPTION => VALUE ... ]
+
+Returns detail information for the invoice line item detail formatted for
+display.
+
+Currently available options are: I<format> I<escape_function>
+
+If I<format> is set to html or latex then the format is improved
+for tabular appearance in those environments if possible.
+
+If I<escape_function> is set then the format is processed by this
+function before being returned.
+
+If I<format_function> is set then the detail is handed to this callback
+for processing.
+
+=cut
+
+sub formatted {
+ my ( $self, %opt ) = @_;
+ my $format = $opt{format} || '';
+ return () unless defined dbdef->table('cust_bill_pkg_detail');
+
+ eval "use Text::CSV_XS;";
+ die $@ if $@;
+ my $csv = new Text::CSV_XS;
+
+ my $escape_function = sub { shift };
+
+ $escape_function = \&encode_entities
+ if $format eq 'html';
+
+ $escape_function =
+ sub {
+ my $value = shift;
+ $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge;
+ $value =~ s/([<>])/\$$1\$/g;
+ $value;
+ }
+ if $format eq 'latex';
+
+ $escape_function = $opt{escape_function} if $opt{escape_function};
+
+ my $format_sub = sub { my $detail = shift;
+ $csv->parse($detail) or return "can't parse $detail";
+ join(' - ', map { &$escape_function($_) }
+ $csv->fields
+ );
+ };
+
+ $format_sub = sub { my $detail = shift;
+ $csv->parse($detail) or return "can't parse $detail";
+ join('</TD><TD>', map { &$escape_function($_) }
+ $csv->fields
+ );
+ }
+ if $format eq 'html';
+
+ $format_sub = sub { my $detail = shift;
+ $csv->parse($detail) or return "can't parse $detail";
+ #join(' & ', map { '\small{'. &$escape_function($_). '}' } # $csv->fields );
+ my $result = '';
+ my $column = 1;
+ foreach ($csv->fields) {
+ $result .= ' & ' if $column > 1;
+ if ($column > 6) { # KLUDGE ALERT!
+ $result .= '\multicolumn{1}{l}{\scriptsize{'.
+ &$escape_function($_). '}}';
+ }else{
+ $result .= '\scriptsize{'. &$escape_function($_). '}';
+ }
+ $column++;
+ }
+ $result;
+ }
+ if $format eq 'latex';
+
+ $format_sub = $opt{format_function} if $opt{format_function};
+
+ $self->format eq 'C'
+ ? &{$format_sub}( $self->detail, $self )
+ : &{$escape_function}( $self->detail )
+ ;
+}
+
+
# _upgrade_data
#
# Used by FS::Upgrade to migrate to a new database.
diff --git a/FS/FS/part_pkg/voip_cdr.pm b/FS/FS/part_pkg/voip_cdr.pm
index eccf2c19f..47102c1af 100644
--- a/FS/FS/part_pkg/voip_cdr.pm
+++ b/FS/FS/part_pkg/voip_cdr.pm
@@ -15,7 +15,7 @@ use FS::part_pkg::recur_Common;
@ISA = qw(FS::part_pkg::recur_Common);
-$DEBUG = 0;
+$DEBUG = 1;
tie my %rating_method, 'Tie::IxHash',
'prefix' => 'Rate calls by using destination prefix to look up a region and rate according to the internal prefix and rate tables',
@@ -324,6 +324,8 @@ sub calc_usage {
my( $rate_region, $regionnum );
my $pretty_destnum;
my $charge = '';
+ my $seconds = '';
+ my $regionname = '';
my $classnum = '';
my @call_details = ();
if ( $rating_method eq 'prefix' ) {
@@ -409,6 +411,7 @@ sub calc_usage {
$rate_region = $rate_detail->dest_region;
$regionnum = $rate_region->regionnum;
+ $regionname = $rate_region->regionname;
warn " found rate for regionnum $regionnum ".
"and rate detail $rate_detail\n"
if $DEBUG;
@@ -481,7 +484,7 @@ sub calc_usage {
: 60;
# length($cdr->billsec) ? $cdr->billsec : $cdr->duration;
- my $seconds = $use_duration ? $cdr->duration : $cdr->billsec;
+ $seconds = $use_duration ? $cdr->duration : $cdr->billsec;
$seconds += $granularity - ( $seconds % $granularity )
if $seconds # don't granular-ize 0 billsec calls (bills them)
@@ -530,7 +533,7 @@ sub calc_usage {
my $granularity = $rate_detail->sec_granularity;
# length($cdr->billsec) ? $cdr->billsec : $cdr->duration;
- my $seconds = $use_duration ? $cdr->duration : $cdr->billsec;
+ $seconds = $use_duration ? $cdr->duration : $cdr->billsec;
$seconds += $granularity - ( $seconds % $granularity )
if $seconds # don't granular-ize 0 billsec calls (bills them)
@@ -561,7 +564,7 @@ sub calc_usage {
'minutes' => $minutes,
'charge' => $charge,
'pretty_dst' => $pretty_destnum,
- 'dst_regionname' => $rate_region->regionname,
+ 'dst_regionname' => $regionname,
)
);
@@ -577,11 +580,25 @@ sub calc_usage {
#if ( $self->option('rating_method') eq 'upstream_simple' ) {
if ( scalar(@call_details) == 1 ) {
$call_details =
- [ 'C', $call_details[0], $charge, $classnum, $phonenum ];
+ [ 'C',
+ $call_details[0],
+ $charge,
+ $classnum,
+ $phonenum,
+ $seconds,
+ $regionname,
+ ];
} else { #only used for $rating_method eq 'upstream' now
$csv->combine(@call_details);
$call_details =
- [ 'C', $csv->string, $charge, $classnum, $phonenum ];
+ [ 'C',
+ $csv->string,
+ $charge,
+ $classnum,
+ $phonenum,
+ $seconds,
+ $regionname,
+ ];
}
warn " adding details on charge to invoice: [ ".
join(', ', @{$call_details} ). " ]"
diff --git a/FS/FS/usage_class.pm b/FS/FS/usage_class.pm
index 93a32df47..8ecd41677 100644
--- a/FS/FS/usage_class.pm
+++ b/FS/FS/usage_class.pm
@@ -97,7 +97,9 @@ sub check {
my $error =
$self->ut_numbern('classnum')
+ || $self->ut_numbern('weight')
|| $self->ut_text('classname')
+ || $self->ut_textn('format')
|| $self->ut_enum('disabled', [ '', 'Y' ])
;
return $error if $error;
@@ -105,6 +107,269 @@ sub check {
$self->SUPER::check;
}
+=item summary_formats_labelhash
+
+Returns a list of line item format descriptions suitable for assigning to
+a hash.
+
+=cut
+
+# transform hashes of arrays to arrays of hashes for false laziness removal?
+my %summary_formats = (
+ 'simple' => {
+ 'label' => [ qw( Description Calls Minutes Amount ) ],
+ 'fields' => [
+ sub { shift->{description} },
+ sub { shift->{calls} },
+ sub { sprintf( '%.1f', shift->{duration}/60 ) },
+ sub { shift->{amount} },
+ ],
+ 'align' => [ qw( l r r r ) ],
+ 'span' => [ qw( 4 1 1 1 ) ], # unitprices?
+ 'width' => [ qw( 8.2cm 2.5cm 1.4cm 1.6cm ) ], # don't like this
+ },
+ 'simpler' => {
+ 'label' => [ qw( Description Calls Amount ) ],
+ 'fields' => [
+ sub { shift->{description} },
+ sub { shift->{calls} },
+ sub { shift->{amount} },
+ ],
+ 'align' => [ qw( l r r ) ],
+ 'span' => [ qw( 5 1 1 ) ],
+ 'width' => [ qw( 10.7cm 1.4cm 1.6cm ) ], # don't like this
+ },
+ 'minimal' => {
+ 'label' => [ qw( Amount ) ],
+ 'fields' => [
+ sub { '' },
+ ],
+ 'align' => [ qw( r ) ],
+ 'span' => [ qw( 7 ) ], # unitprices?
+ 'width' => [ qw( 13.8cm ) ], # don't like this
+ },
+);
+
+sub summary_formats_labelhash {
+ map { $_ => join(',', @{$summary_formats{$_}{label}}) } keys %summary_formats;
+}
+
+=item header_generator FORMAT
+
+Returns a coderef used for generation of an invoice line item header for this
+usage_class. FORMAT is either html or latex
+
+=cut
+
+my %html_align = (
+ 'c' => 'center',
+ 'l' => 'left',
+ 'r' => 'right',
+);
+
+sub _generator_defaults {
+ my ( $self, $format ) = ( shift, shift );
+ return ( $summary_formats{$self->format}, ' ', ' ', ' ', sub { shift } );
+}
+
+sub header_generator {
+ my ( $self, $format ) = ( shift, shift );
+
+ my ( $f, $prefix, $suffix, $separator, $column ) =
+ $self->_generator_defaults($format);
+
+ if ($format eq 'latex') {
+ $prefix = "\\hline\n\\rule{0pt}{2.5ex}\n\\makebox[1.4cm]{}&\n";
+ $suffix = "\\\\\n\\hline";
+ $separator = "&\n";
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
+ };
+ } elsif ( $format eq 'html' ) {
+ $prefix = '<th></th>';
+ $suffix = '';
+ $separator = '';
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return qq!<th align="$html_align{$a}">$d</th>!;
+ };
+ }
+
+ sub {
+ my @args = @_;
+ my @result = ();
+
+ foreach (my $i = 0; $f->{label}->[$i]; $i++) {
+ push @result,
+ &{$column}( map { $f->{$_}->[$i] } qw(label align span width) );
+ }
+
+ $prefix. join($separator, @result). $suffix;
+ };
+
+}
+
+=item description_generator FORMAT
+
+Returns a coderef used for generation of invoice line items for this
+usage_class. FORMAT is either html or latex
+
+=cut
+
+sub description_generator {
+ my ( $self, $format ) = ( shift, shift );
+
+ my ( $f, $prefix, $suffix, $separator, $column ) =
+ $self->_generator_defaults($format);
+
+ if ($format eq 'latex') {
+ $prefix = "\\hline\n\\multicolumn{1}{c}{\\rule{0pt}{2.5ex}~} &\n";
+ $suffix = '\\\\';
+ $separator = " & \n";
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
+ };
+ }elsif ( $format eq 'html' ) {
+ $prefix = '"><td align="center"></td>';
+ $suffix = '';
+ $separator = '';
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return qq!<td align="$html_align{$a}">$d</td>!;
+ };
+ }
+
+ sub {
+ my @args = @_;
+ my @result = ();
+
+ foreach (my $i = 0; $f->{label}->[$i]; $i++) {
+ push @result, &{$column}( &{$f->{fields}->[$i]}(@args),
+ map { $f->{$_}->[$i] } qw(align span width)
+ );
+ }
+
+ $prefix. join( $separator, @result ). $suffix;
+ };
+
+}
+
+=item total_generator FORMAT
+
+Returns a coderef used for generation of invoice total lines for this
+usage_class. FORMAT is either html or latex
+
+=cut
+
+sub total_generator {
+ my ( $self, $format ) = ( shift, shift );
+
+# $OUT .= '\FStotaldesc{' . $section->{'description'} . ' Total}' .
+# '{' . $section->{'subtotal'} . '}' . "\n";
+
+ my ( $f, $prefix, $suffix, $separator, $column ) =
+ $self->_generator_defaults($format);
+ my $style = '';
+
+ if ($format eq 'latex') {
+ $prefix = "& ";
+ $suffix = "\\\\\n";
+ $separator = " & \n";
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
+ };
+ }elsif ( $format eq 'html' ) {
+ $prefix = '';
+ $suffix = '';
+ $separator = '';
+ $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
+ };
+ }
+
+
+ sub {
+ my @args = @_;
+ my @result = ();
+
+ # my $r = &{$f->{fields}->[$i]}(@args);
+ # $r .= ' Total' unless $i;
+
+ foreach (my $i = 0; $f->{label}->[$i]; $i++) {
+ push @result,
+ &{$column}( &{$f->{fields}->[$i]}(@args). ($i ? '' : ' Total'),
+ map { $f->{$_}->[$i] } qw(align span width)
+ );
+ }
+
+ $prefix. join( $separator, @result ). $suffix;
+ };
+
+}
+
+=item total_line_generator FORMAT
+
+Returns a coderef used for generation of invoice total line items for this
+usage_class. FORMAT is either html or latex
+
+=cut
+
+# not used: will have issues with hash element names (description vs
+# total_item and amount vs total_amount -- another array of functions?
+
+sub total_line_generator {
+ my ( $self, $format ) = ( shift, shift );
+
+# $OUT .= '\FStotaldesc{' . $line->{'total_item'} . '}' .
+# '{' . $line->{'total_amount'} . '}' . "\n";
+
+ my ( $f, $prefix, $suffix, $separator, $column ) =
+ $self->_generator_defaults($format);
+ my $style = '';
+
+ if ($format eq 'latex') {
+ $prefix = "& ";
+ $suffix = "\\\\\n";
+ $separator = " & \n";
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
+ };
+ }elsif ( $format eq 'html' ) {
+ $prefix = '';
+ $suffix = '';
+ $separator = '';
+ $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
+ };
+ }
+
+
+ sub {
+ my @args = @_;
+ my @result = ();
+
+ foreach (my $i = 0; $f->{label}->[$i]; $i++) {
+ push @result,
+ &{$column}( &{$f->{fields}->[$i]}(@args),
+ map { $f->{$_}->[$i] } qw(align span width)
+ );
+ }
+
+ $prefix. join( $separator, @result ). $suffix;
+ };
+
+}
+
+
+
sub _populate_initial_data {
my ($class, %opts) = @_;