From 62bfeae993beb7f98d50d319360f1fece128982b Mon Sep 17 00:00:00 2001 From: jeff Date: Fri, 20 Nov 2009 17:33:40 +0000 Subject: [PATCH] invoice formatting: add sections for usage, add sections per svc_phone, add folding like line items into one #6592 --- FS/FS/Conf.pm | 14 + FS/FS/Schema.pm | 11 +- FS/FS/cust_bill.pm | 553 +++++++++++++++++++++++++++++++++++- FS/FS/cust_bill_pkg.pm | 21 ++ FS/FS/cust_bill_pkg_detail.pm | 101 +++++++ FS/FS/part_pkg/voip_cdr.pm | 29 +- FS/FS/usage_class.pm | 265 +++++++++++++++++ conf/invoice_html | 96 ++++--- httemplate/browse/pkg_category.html | 8 +- httemplate/browse/usage_class.html | 23 +- httemplate/edit/pkg_category.html | 27 +- httemplate/edit/usage_class.html | 23 +- 12 files changed, 1098 insertions(+), 73 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 = ''; + $suffix = ''; + $separator = ''; + $column = + sub { my ($d,$a,$s,$w) = @_; + return qq!$d!; + }; + } + + 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 = '">'; + $suffix = ''; + $separator = ''; + $column = + sub { my ($d,$a,$s,$w) = @_; + return qq!$d!; + }; + } + + 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!$d!; + }; + } + + + 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!$d!; + }; + } + + + 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 I + +If I is set to html or latex then the format is improved +for tabular appearance in those environments if possible. + +If I is set then the format is processed by this +function before being returned. + +If I 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('', 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 = ''; + $suffix = ''; + $separator = ''; + $column = + sub { my ($d,$a,$s,$w) = @_; + return qq!$d!; + }; + } + + 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 = '">'; + $suffix = ''; + $separator = ''; + $column = + sub { my ($d,$a,$s,$w) = @_; + return qq!$d!; + }; + } + + 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!$d!; + }; + } + + + 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!$d!; + }; + } + + + 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) = @_; diff --git a/conf/invoice_html b/conf/invoice_html index b7f7b9789..df4674b6a 100644 --- a/conf/invoice_html +++ b/conf/invoice_html @@ -114,15 +114,20 @@ $OUT .= ''. - ''. - ''. - ''. - ( $unitprices - ? ''. - '' - : '' - ). - ''. + ''; + + if ($section->{header_generator}) { + $OUT .= &{$section->{header_generator}}(); + } else { + $OUT .= ''. + ''. + ( $unitprices + ? ''. + '' + : '' + ). + ''; + } ''; my $lastref = 0; @@ -134,21 +139,24 @@ @detail_items ) { $OUT .= - ''. - ''. - ''. - ( $unitprices - ? ''. - '' - : '' - ). - - ''. - '' - ; + ''. + ''. + ''. + ( $unitprices + ? ''. + '' + : '' + ). + + ''; + } + $OUT .= ''; $lastref = $line->{'ref'}; if ( @{$line->{'ext_description'} } ) { $OUT .= ' ). - qq('. - qq('. - '' - ; + qq(); + if ($section->{total_generator}) { + $OUT .= &{$section->{total_generator}}($section); + } else { + $OUT .= qq('. + qq('; + } + $OUT .= ''; } } if ($section->{'posttotal'}) { @@ -203,15 +214,18 @@ if ++$linenum == scalar(@total_items); $OUT .= - ''. - qq(). - qq('. - qq('. - '' - ; + ''; + if ($section->{total_line_generator}) { + $OUT .= &{$section->{total_line_generator}}($line); + } else { + $OUT .= qq(). + qq('. + qq('; + } + $OUT .= ''; $style=''; diff --git a/httemplate/browse/pkg_category.html b/httemplate/browse/pkg_category.html index a156c06da..16da23066 100644 --- a/httemplate/browse/pkg_category.html +++ b/httemplate/browse/pkg_category.html @@ -3,15 +3,15 @@ 'html_init' => $html_init, 'name' => 'package categories', 'disableable' => 1, - 'disabled_statuspos' => 2, + 'disabled_statuspos' => 3, 'query' => { 'table' => 'pkg_category', 'hashref' => {}, 'extra_sql' => 'ORDER BY categorynum', }, 'count_query' => $count_query, - 'header' => [ '#', 'Category', 'Weight' ], - 'fields' => [ 'categorynum', 'categoryname', 'weight' ], - 'links' => [ $link, $link, $link ], + 'header' => [ '#', 'Category', 'Weight', 'Condense' ], + 'fields' => [ 'categorynum', 'categoryname', 'weight', 'condense' ], + 'links' => [ $link, $link, $link, $link ], ) %> diff --git a/httemplate/browse/usage_class.html b/httemplate/browse/usage_class.html index 63fd2c5a2..75223e025 100644 --- a/httemplate/browse/usage_class.html +++ b/httemplate/browse/usage_class.html @@ -9,9 +9,21 @@ 'extra_sql' => 'ORDER BY classnum', }, 'count_query' => 'SELECT COUNT(*) FROM usage_class', - 'header' => [ '#', 'Class' ], - 'fields' => [ 'classnum', 'classname' ], - 'links' => [ $link, $link ], + 'header' => [ '#', + 'Class', + 'Weight', + ( $useformat ? ('Format') : () ), + ], + 'fields' => [ 'classnum', + 'classname', + 'weight', + ( $useformat ? (sub { $labels->{shift->format} } ) : () ), + ], + 'links' => [ $link, + $link, + $link, + ( $useformat ? ( $link ) : () ), + ], ) %> <%init> @@ -19,6 +31,11 @@ die "access denied" unless $FS::CurrentUser::CurrentUser->access_right('Configuration'); +my $conf = new FS::Conf; +my $useformat = $conf->exists('usage_class_as_a_section'); +my $labels = { &FS::usage_class::summary_formats_labelhash() }; + + my $html_init = 'Usage classes define groups of usage for taxation purposes.

'. qq!Add a usage class

!; diff --git a/httemplate/edit/pkg_category.html b/httemplate/edit/pkg_category.html index a244bd53a..20e109383 100644 --- a/httemplate/edit/pkg_category.html +++ b/httemplate/edit/pkg_category.html @@ -1,5 +1,28 @@ -<% include( 'elements/category_Common.html', +<% include( 'elements/edit.html', 'name' => 'Package Category', 'table' => 'pkg_category', - ) + 'fields' => [ + 'categoryname', + 'weight', + { field=>'condense', type=>'checkbox', value=>'Y', }, + { field=>'disabled', type=>'checkbox', value=>'Y', }, + ], + 'labels' => { + 'categorynum' => 'Category number', + 'categoryname' => 'Category name', + 'weight' => 'Weight', + 'condense' => 'Collapse identical items to one', + 'disabled' => 'Disable category', + }, + 'viewall_dir' => 'browse', + %opt, + ) %> +<%init> + +die "access denied" + unless $FS::CurrentUser::CurrentUser->access_right('Configuration'); + +my %opt = @_; + + diff --git a/httemplate/edit/usage_class.html b/httemplate/edit/usage_class.html index ef4b1fff4..be01d2e67 100644 --- a/httemplate/edit/usage_class.html +++ b/httemplate/edit/usage_class.html @@ -3,14 +3,26 @@ 'table' => 'usage_class', 'fields' => [ 'classname', - { field=>'disabled', - type=>'checkbox', - value=>'Y', + 'weight', + { field => 'format', + type => $useformat ? 'select' : 'hidden', + ( $useformat + ? ( 'options' => [ keys %labels ], + 'labels' => \%labels, + ) + : () + ), + }, + { field => 'disabled', + type => 'checkbox', + value => 'Y', }, ], 'labels' => { 'classnum' => 'Class number', 'classname' => 'Class name', + 'weight' => 'Weight', + 'format' => 'Format', 'disabled' => 'Disable class', }, 'viewall_dir' => 'browse', @@ -22,4 +34,9 @@ die "access denied" unless $FS::CurrentUser::CurrentUser->access_right('Configuration'); +my $conf = new FS::Conf; +my $useformat = $conf->exists('usage_class_as_a_section'); + +my %labels = &FS::usage_class::summary_formats_labelhash(); + -- 2.11.0
RefDescriptionUnit PriceQuantityAmount
RefDescriptionUnit PriceQuantityAmount
'. - ( $line->{'ref'} ne $lastref ? $line->{'ref'} : '' ). ''. $line->{'description'}. ''. $line->{'unit_amount'}. ''. $line->{'quantity'}. ''. $line->{'amount'}. '
'. + ( $line->{'ref'} ne $lastref ? $line->{'ref'} : '' ). ''. $line->{'description'}. ''. $line->{'unit_amount'}. ''. $line->{'quantity'}. ''. $line->{'amount'}. '
' : '>' ). - $section->{'description'}. ' Total ). - $section->{'subtotal'}. '
 ' : '>' ). + $section->{'description'}. ' Total ). + $section->{'subtotal'}. '
 ' : '>' ). - $line->{'total_item'}. '). - $line->{'total_amount'}. '
 ' : '>' ). + $line->{'total_item'}. '). + $line->{'total_amount'}. '