X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fquotation.pm;h=930083e107ca1df98ebcaeaf7a73aa70638a99bb;hb=bc75a214c30ca0ae7554cc60d4f7754f5ea03366;hp=9cef3c19146433716a9a2759f4232e5166a7d9a0;hpb=7bc49d6e6a03b181ca2392d69e5f717e54d2f907;p=freeside.git diff --git a/FS/FS/quotation.pm b/FS/FS/quotation.pm index 9cef3c191..930083e10 100644 --- a/FS/FS/quotation.pm +++ b/FS/FS/quotation.pm @@ -5,14 +5,19 @@ use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::otaker_Mixin FS::Record use strict; use Tie::RefHash; use FS::CurrentUser; -use FS::UID qw( dbh ); +use FS::UID qw( dbh myconnect ); use FS::Maketext qw( emt ); -use FS::Record qw( qsearchs ); +use FS::Record qw( qsearch qsearchs ); +use FS::Conf; use FS::cust_main; use FS::cust_pkg; use FS::quotation_pkg; +use FS::quotation_pkg_tax; use FS::type_pkgs; +our $DEBUG = 1; +use Data::Dumper; + =head1 NAME FS::quotation - Object methods for quotation records @@ -248,34 +253,87 @@ sub cust_or_prospect_label_link { } -#prevent things from falsely showing up as taxes, at least until we support -# quoting tax amounts.. sub _items_tax { - return (); + (); } + sub _items_nontax { shift->cust_bill_pkg; } sub _items_total { - my( $self, $total_items ) = @_; + my $self = shift; + $self->quotationnum =~ /^(\d+)$/ or return (); + + my @items; + + # show taxes in here also; the setup/recurring breakdown is different + # from what Template_Mixin expects + my @setup_tax = qsearch({ + select => 'itemdesc, SUM(setup_amount) as setup_amount', + table => 'quotation_pkg_tax', + addl_from => ' JOIN quotation_pkg USING (quotationpkgnum) ', + extra_sql => ' WHERE quotationnum = '.$1, + order_by => ' GROUP BY itemdesc HAVING SUM(setup_amount) > 0' . + ' ORDER BY itemdesc', + }); + # recurs need to be grouped by frequency, and to have a pkgpart + my @recur_tax = qsearch({ + select => 'freq, itemdesc, SUM(recur_amount) as recur_amount, MAX(pkgpart) as pkgpart', + table => 'quotation_pkg_tax', + addl_from => ' JOIN quotation_pkg USING (quotationpkgnum)'. + ' JOIN part_pkg USING (pkgpart)', + extra_sql => ' WHERE quotationnum = '.$1, + order_by => ' GROUP BY freq, itemdesc HAVING SUM(recur_amount) > 0' . + ' ORDER BY freq, itemdesc', + }); + + my $total_setup = $self->total_setup; + foreach my $pkg_tax (@setup_tax) { + if ($pkg_tax->setup_amount > 0) { + $total_setup += $pkg_tax->setup_amount; + push @items, { + 'total_item' => $pkg_tax->itemdesc . ' ' . $self->mt('(setup)'), + 'total_amount' => $pkg_tax->setup_amount, + }; + } + } - if ( $self->total_setup > 0 ) { - push @$total_items, { + if ( $total_setup > 0 ) { + push @items, { 'total_item' => $self->mt( $self->total_recur > 0 ? 'Total Setup' : 'Total' ), - 'total_amount' => $self->total_setup, + 'total_amount' => sprintf('%.2f',$total_setup), + 'break_after' => ( scalar(@recur_tax) ? 1 : 0 ) }; } #could/should add up the different recurring frequencies on lines of their own # but this will cover the 95% cases for now - if ( $self->total_recur > 0 ) { - push @$total_items, { + my $total_recur = $self->total_recur; + # label these with the frequency + foreach my $pkg_tax (@recur_tax) { + if ($pkg_tax->recur_amount > 0) { + $total_recur += $pkg_tax->recur_amount; + # an arbitrary part_pkg, but with the right frequency + # XXX localization + my $part_pkg = qsearchs('part_pkg', { pkgpart => $pkg_tax->pkgpart }); + push @items, { + 'total_item' => $pkg_tax->itemdesc . ' (' . $part_pkg->freq_pretty . ')', + 'total_amount' => $pkg_tax->recur_amount, + }; + } + } + + if ( $total_recur > 0 ) { + push @items, { 'total_item' => $self->mt('Total Recurring'), - 'total_amount' => $self->total_recur, + 'total_amount' => sprintf('%.2f',$total_recur), + 'break_after' => 1, }; } + return @items; + } =item enable_previous @@ -328,7 +386,7 @@ sub convert_cust_main { } -=item order +=item order [ HASHREF ] This method is for use with quotations which are already associated with a customer. @@ -336,14 +394,21 @@ Orders this quotation's packages as real packages for the customer. If there is an error, returns an error message, otherwise returns false. +If HASHREF is passed, it will be filled with a hash mapping the +C of each quoted package to the C of the package +as ordered. + =cut sub order { my $self = shift; + my $pkgnum_map = shift || {}; tie my %all_cust_pkg, 'Tie::RefHash'; foreach my $quotation_pkg ($self->quotation_pkg) { my $cust_pkg = FS::cust_pkg->new; + $pkgnum_map->{ $quotation_pkg->quotationpkgnum } = $cust_pkg; + foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) { $cust_pkg->set( $_, $quotation_pkg->get($_) ); } @@ -357,8 +422,15 @@ sub order { $all_cust_pkg{$cust_pkg} = []; # no services } - $self->cust_main->order_pkgs( \%all_cust_pkg ); + my $error = $self->cust_main->order_pkgs( \%all_cust_pkg ); + + foreach my $quotationpkgnum (keys %$pkgnum_map) { + # convert the objects to just pkgnums + my $cust_pkg = $pkgnum_map->{$quotationpkgnum}; + $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum; + } + $error; } =item charge @@ -396,7 +468,7 @@ sub charge { $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : ''; $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : ''; $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : ''; - $locationnum = $_[0]->{locationnum} || $self->ship_locationnum; + $locationnum = $_[0]->{locationnum}; } else { $amount = shift; $setup_cost = ''; @@ -517,6 +589,208 @@ sub enable { $self->replace(); } +=item estimate + +Calculates current prices for all items on this quotation, including +discounts and taxes, and updates the quotation_pkg records accordingly. + +=cut + +sub estimate { + my $self = shift; + my $conf = FS::Conf->new; + + my %pkgnum_of; # quotationpkgnum => temporary pkgnum + + my $me = "[quotation #".$self->quotationnum."]"; # for debug messages + + my @return_bill = ([]); + my $error; + + ###### BEGIN TRANSACTION ###### + local $@; + eval { + my $temp_dbh = myconnect(); + local $FS::UID::dbh = $temp_dbh; + local $FS::UID::AutoCommit = 0; + + my $fake_self = FS::quotation->new({ $self->hash }); + + # if this is a prospect, make them into a customer for now + # XXX prospects currently can't have service locations + my $cust_or_prospect = $self->cust_or_prospect; + my $cust_main; + if ( $cust_or_prospect->isa('FS::prospect_main') ) { + $cust_main = $cust_or_prospect->convert_cust_main; + die "$cust_main (simulating customer signup)\n" unless ref $cust_main; + $fake_self->set('prospectnum', ''); + $fake_self->set('custnum', $cust_main->custnum); + } else { + $cust_main = $cust_or_prospect; + } + + # order packages + $error = $fake_self->order(\%pkgnum_of); + die "$error (simulating package order)\n" if $error; + + my @new_pkgs = map { FS::cust_pkg->by_key($_) } values(%pkgnum_of); + + # simulate the first bill + my %bill_opt = ( + 'pkg_list' => \@new_pkgs, + 'time' => time, # an option to adjust this? + 'return_bill' => $return_bill[0], + 'no_usage_reset' => 1, + ); + $error = $cust_main->bill(%bill_opt); + die "$error (simulating initial billing)\n" if $error; + + # pick dates for future bills + my %next_bill_pkgs; + foreach (@new_pkgs) { + my $bill = $_->get('bill'); + next if !$bill; + push @{ $next_bill_pkgs{$bill} ||= [] }, $_; + } + + my $i = 1; + foreach my $next_bill (keys %next_bill_pkgs) { + $bill_opt{'time'} = $next_bill; + $bill_opt{'return_bill'} = $return_bill[$i] = []; + $bill_opt{'pkg_list'} = $next_bill_pkgs{$next_bill}; + $error = $cust_main->bill(%bill_opt); + die "$error (simulating recurring billing cycle $i)\n" if $error; + $i++; + } + + $temp_dbh->rollback; + }; + return $@ if $@; + ###### END TRANSACTION ###### + my %quotationpkgnum_of = reverse %pkgnum_of; + + if ($DEBUG) { + warn "pkgnums:\n".Dumper(\%pkgnum_of); + warn Dumper(\@return_bill); + } + + # careful: none of the pkgnums in here are correct outside the sandbox. + my %quotation_pkg; # quotationpkgnum => quotation_pkg + foreach my $qp ($self->quotation_pkg) { + $quotation_pkg{$qp->quotationpkgnum} = $qp; + $qp->set($_, 0) foreach qw(unitsetup unitrecur); + $qp->set('freq', ''); + # flush old tax records + foreach ($qp->quotation_pkg_tax, $qp->quotation_pkg_discount) { + $error = $_->delete; + return "$error (flushing tax records for pkgpart ".$qp->part_pkg->pkgpart.")" + if $error; + } + } + + my %quotation_pkg_tax; # quotationpkgnum => taxnum => quotation_pkg_tax obj + + for (my $i = 0; $i < scalar(@return_bill); $i++) { + my $this_bill = $return_bill[$i]->[0]; + if (!$this_bill) { + warn "$me billing cycle $i produced no invoice\n"; + next; + } + + my @nonpkg_lines; + my %cust_bill_pkg; + foreach my $cust_bill_pkg (@{ $this_bill->get('cust_bill_pkg') }) { + my $pkgnum = $cust_bill_pkg->pkgnum; + $cust_bill_pkg{ $cust_bill_pkg->billpkgnum } = $cust_bill_pkg; + if ( !$pkgnum ) { + # taxes/fees; come back to it + push @nonpkg_lines, $cust_bill_pkg; + next; + } + my $quotationpkgnum = $quotationpkgnum_of{$pkgnum}; + my $qp = $quotation_pkg{$quotationpkgnum}; + if (!$qp) { + # XXX supplemental packages could do this (they have separate pkgnums) + # handle that special case at some point + warn "$me simulated bill returned a package not on the quotation (pkgpart ".$cust_bill_pkg->pkgpart.")\n"; + next; + } + if ( $i == 0 ) { + # then this is the first (setup) invoice + $qp->set('start_date', $cust_bill_pkg->sdate); + $qp->set('unitsetup', $qp->unitsetup + $cust_bill_pkg->unitsetup); + # pkgpart_override is a possibility + } else { + # recurring invoice (should be only one of these per package, though + # it may have multiple lineitems with the same pkgnum) + $qp->set('unitrecur', $qp->unitrecur + $cust_bill_pkg->unitrecur); + } + } + foreach my $cust_bill_pkg (@nonpkg_lines) { + if ($cust_bill_pkg->feepart) { + warn "$me simulated bill included a non-package fee (feepart ". + $cust_bill_pkg->feepart.")\n"; + next; + } + my $links = $cust_bill_pkg->get('cust_bill_pkg_tax_location') || + $cust_bill_pkg->get('cust_bill_pkg_tax_rate_location') || + []; + # breadth-first unrolled recursion + while (my $tax_link = shift @$links) { + my $target = $cust_bill_pkg{ $tax_link->taxable_billpkgnum } + or die "$me unable to resolve tax link (taxnum ".$tax_link->taxnum.")\n"; + if ($target->pkgnum) { + my $quotationpkgnum = $quotationpkgnum_of{$target->pkgnum}; + # create this if there isn't one yet + my $qpt = + $quotation_pkg_tax{$quotationpkgnum}{$tax_link->taxnum} ||= + FS::quotation_pkg_tax->new({ + quotationpkgnum => $quotationpkgnum, + itemdesc => $cust_bill_pkg->itemdesc, + taxnum => $tax_link->taxnum, + taxtype => $tax_link->taxtype, + setup_amount => 0, + recur_amount => 0, + }); + if ( $i == 0 ) { # first invoice + $qpt->set('setup_amount', $qpt->setup_amount + $tax_link->amount); + } else { # subsequent invoices + # this isn't perfectly accurate, but that's why it's an estimate + $qpt->set('recur_amount', $qpt->recur_amount + $tax_link->amount); + $qpt->set('setup_amount', sprintf('%.2f', $qpt->setup_amount - $tax_link->amount)); + $qpt->set('setup_amount', 0) if $qpt->setup_amount < 0; + } + } elsif ($target->feepart) { + # do nothing; we already warned for the fee itself + } else { + # tax on tax: the tax target is another tax item + # since this is an estimate, I'm just going to assign it to the + # first of the underlying packages + my $sublinks = $target->cust_bill_pkg_tax_rate_location; + if ($sublinks and $sublinks->[0]) { + $tax_link->set('taxable_billpkgnum', $sublinks->[0]->taxable_billpkgnum); + push @$links, $tax_link; #try again + } else { + warn "$me unable to assign tax on tax; ignoring\n"; + } + } + } # while my $tax_link + } # foreach my $cust_bill_pkg + #XXX discounts + } + foreach my $quotation_pkg (values %quotation_pkg) { + $error = $quotation_pkg->replace; + return "$error (recording estimate for ".$quotation_pkg->part_pkg->pkg.")" + if $error; + } + foreach my $quotation_pkg_tax (map { values %$_ } values %quotation_pkg_tax) { + $error = $quotation_pkg_tax->insert; + return "$error (recording estimated tax for ".$quotation_pkg_tax->itemdesc.")" + if $error; + } + return; +} + =back =head1 CLASS METHODS @@ -649,15 +923,9 @@ first, and doesn't implement the "condensed" option. sub _items_pkg { my ($self, %options) = @_; - my @quotation_pkg = $self->quotation_pkg; - foreach (@quotation_pkg) { - my $error = $_->estimate; - die "error calculating estimate for pkgpart " . $_->pkgpart.": $error\n" - if $error; - } - + $self->estimate; # run it through the Template_Mixin engine - return $self->_items_cust_bill_pkg(\@quotation_pkg, %options); + return $self->_items_cust_bill_pkg([ $self->quotation_pkg ], %options); } =back