Merge branch 'FREESIDE_3_BRANCH' of git.freeside.biz:/home/git/freeside into FREESIDE...
[freeside.git] / FS / FS / quotation.pm
index 0350047..38b9cd2 100644 (file)
@@ -7,10 +7,12 @@ use FS::CurrentUser;
 use FS::UID qw( dbh );
 use FS::Maketext qw( emt );
 use FS::Record qw( qsearch qsearchs );
 use FS::UID qw( dbh );
 use FS::Maketext qw( emt );
 use FS::Record qw( qsearch qsearchs );
+use FS::Conf;
 use FS::cust_main;
 use FS::cust_pkg;
 use FS::prospect_main;
 use FS::quotation_pkg;
 use FS::cust_main;
 use FS::cust_pkg;
 use FS::prospect_main;
 use FS::quotation_pkg;
+use FS::quotation_pkg_tax;
 use FS::type_pkgs;
 
 =head1 NAME
 use FS::type_pkgs;
 
 =head1 NAME
@@ -63,6 +65,13 @@ disabled
 
 usernum
 
 
 usernum
 
+=item close_date
+
+projected date when the quotation will be closed
+
+=item confidence
+
+projected confidence (expressed as integer) that quotation will close
 
 =back
 
 
 =back
 
@@ -115,6 +124,8 @@ sub check {
     || $self->ut_numbern('_date')
     || $self->ut_enum('disabled', [ '', 'Y' ])
     || $self->ut_numbern('usernum')
     || $self->ut_numbern('_date')
     || $self->ut_enum('disabled', [ '', 'Y' ])
     || $self->ut_numbern('usernum')
+    || $self->ut_numbern('close_date')
+    || $self->ut_numbern('confidence')
   ;
   return $error if $error;
 
   ;
   return $error if $error;
 
@@ -122,6 +133,9 @@ sub check {
 
   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
 
 
   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
 
+  return 'confidence must be an integer between 1 and 100'
+    if length($self->confidence) && (($self->confidence < 1) || ($self->confidence > 100));
+
   return 'prospectnum or custnum must be specified'
     if ! $self->prospectnum
     && ! $self->custnum;
   return 'prospectnum or custnum must be specified'
     if ! $self->prospectnum
     && ! $self->custnum;
@@ -263,34 +277,120 @@ 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 {
 sub _items_tax {
-  return ();
+  ();
 }
 }
+
 sub _items_nontax {
   shift->cust_bill_pkg;
 }
 
 sub _items_total {
 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',
+  });
 
 
-  if ( $self->total_setup > 0 ) {
-    push @$total_items, {
-      'total_item'   => $self->mt( $self->total_recur > 0 ? 'Total Setup' : 'Total' ),
-      'total_amount' => $self->total_setup,
+  my $total_setup = $self->total_setup;
+  my $total_recur = $self->total_recur;
+  my $setup_show = $total_setup > 0 ? 1 : 0;
+  my $recur_show = $total_recur > 0 ? 1 : 0;
+  unless ($setup_show && $recur_show) {
+    foreach my $quotation_pkg ($self->quotation_pkg) {
+      $setup_show = 1 if !$setup_show and $quotation_pkg->setup_show_zero;
+      $recur_show = 1 if !$recur_show and $quotation_pkg->recur_show_zero;
+      last if $setup_show && $recur_show;
+    }
+  }
+
+  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 ( $setup_show ) {
+    push @items, {
+      'total_item'   => $self->mt( $recur_show ? 'Total Setup' : 'Total' ),
+      '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
     };
   }
 
   #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, {
+  # 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 ( $recur_show ) {
+    push @items, {
       'total_item'   => $self->mt('Total Recurring'),
       'total_item'   => $self->mt('Total Recurring'),
-      'total_amount' => $self->total_recur,
+      'total_amount' => sprintf('%.2f',$total_recur),
+      'break_after'  => 1,
     };
     };
+    # show 'first payment' line (setup + recur) if there are no prorated 
+    # packages included
+    my $disable_total = 0;
+    foreach my $quotation_pkg ($self->quotation_pkg) {
+      my $part_pkg = $quotation_pkg->part_pkg;
+      if (    $part_pkg->plan =~ /^(prorate|torrus|agent$)/
+           || $part_pkg->option('recur_method') eq 'prorate'
+           || ( $part_pkg->option('sync_bill_date')
+                  && $self->custnum
+                  && $self->cust_main->billing_pkgs #num_billing_pkgs when we have it
+              )
+      ) {
+        $disable_total = 1;
+        last;
+      }
+    }
+    if (!$disable_total) {
+      push @items, {
+        'total_item'   => $self->mt('First payment'),
+        'total_amount' => sprintf('%.2f', $total_setup + $total_recur),
+        'break_after'  => 1,
+      };
+    }
   }
 
   }
 
+  return @items;
+
 }
 
 =item enable_previous
 }
 
 =item enable_previous
@@ -343,7 +443,7 @@ sub convert_cust_main {
 
 }
 
 
 }
 
-=item order
+=item order [ HASHREF ]
 
 This method is for use with quotations which are already associated with a customer.
 
 
 This method is for use with quotations which are already associated with a customer.
 
@@ -351,14 +451,27 @@ Orders this quotation's packages as real packages for the customer.
 
 If there is an error, returns an error message, otherwise returns false.
 
 
 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<quotationpkgnum> of each quoted package to the C<pkgnum> of the package
+as ordered.
+
 =cut
 
 sub order {
   my $self = shift;
 =cut
 
 sub order {
   my $self = shift;
+  my $pkgnum_map = shift || {};
+  my $details_map = {};
 
   tie my %all_cust_pkg, 'Tie::RefHash';
   foreach my $quotation_pkg ($self->quotation_pkg) {
     my $cust_pkg = FS::cust_pkg->new;
 
   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;
+
+    # details will be copied below, after package is ordered
+    $details_map->{ $quotation_pkg->quotationpkgnum } = [ 
+      map { $_->copy_on_order ? $_->detail : () } $quotation_pkg->quotation_pkg_detail
+    ];
+
     foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
       $cust_pkg->set( $_, $quotation_pkg->get($_) );
     }
     foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
       $cust_pkg->set( $_, $quotation_pkg->get($_) );
     }
@@ -372,7 +485,44 @@ sub order {
     $all_cust_pkg{$cust_pkg} = []; # no services
   }
 
     $all_cust_pkg{$cust_pkg} = []; # no services
   }
 
-  $self->cust_main->order_pkgs( \%all_cust_pkg );
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $error = $self->cust_main->order_pkgs( \%all_cust_pkg );
+  
+  unless ($error) {
+    # copy details (copy_on_order filtering handled above)
+    foreach my $quotationpkgnum (keys %$details_map) {
+      next unless @{$details_map->{$quotationpkgnum}};
+      $error = $pkgnum_map->{$quotationpkgnum}->set_cust_pkg_detail(
+        'I',
+        @{$details_map->{$quotationpkgnum}}
+      );
+      last if $error;
+    }
+  }
+
+  foreach my $quotationpkgnum (keys %$pkgnum_map) {
+    # convert the objects to just pkgnums
+    my $cust_pkg = $pkgnum_map->{$quotationpkgnum};
+    $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum;
+  }
+
+  if ($error) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  ''; #no error
 
 }
 
 
 }
 
@@ -420,7 +570,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} : '';
     $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 = '';
   } else {
     $amount     = shift;
     $setup_cost = '';
@@ -541,6 +691,139 @@ sub enable {
   $self->replace();
 }
 
   $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 $dbh = dbh;
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+
+  # bring individual items up to date (set setup/recur and discounts)
+  my @quotation_pkg = $self->quotation_pkg;
+  foreach my $pkg (@quotation_pkg) {
+    my $error = $pkg->estimate;
+    if ($error) {
+      $dbh->rollback if $oldAutoCommit;
+      die "error calculating estimate for pkgpart " . $pkg->pkgpart.": $error\n";
+    }
+
+    # delete old tax records
+    foreach my $quotation_pkg_tax ($pkg->quotation_pkg_tax) {
+      $error = $quotation_pkg_tax->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        die "error flushing tax records for pkgpart ". $pkg->pkgpart.": $error\n";
+      }
+    }
+  }
+
+  # annoyingly duplicates handle_taxes--fix this in 4.x 
+  if ( $conf->exists('enable_taxproducts') ) {
+    warn "can't calculate external taxes for quotations yet\n";
+    # then we're done
+    return;
+  }
+
+  my %taxnum_exemptions; # for monthly exemptions; as yet unused
+
+  foreach my $pkg (@quotation_pkg) {
+    my $location = $pkg->cust_location;
+
+    my $part_item = $pkg->part_pkg; # we don't have fees on these yet
+    my @loc_keys = qw( district city county state country);
+    my %taxhash = map { $_ => $location->$_ } @loc_keys;
+    $taxhash{'taxclass'} = $part_item->taxclass;
+    my @taxes;
+    my %taxhash_elim = %taxhash;
+    my @elim = qw( district city county state );
+    do {
+      @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
+      if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
+        #then try a match without taxclass
+        my %no_taxclass = %taxhash_elim;
+        $no_taxclass{ 'taxclass' } = '';
+        @taxes = qsearch( 'cust_main_county', \%no_taxclass );
+      }
+    
+      $taxhash_elim{ shift(@elim) } = '';
+    } while ( !scalar(@taxes) && scalar(@elim) );
+
+    foreach my $tax_def (@taxes) {
+      my $taxnum = $tax_def->taxnum;
+      $taxnum_exemptions{$taxnum} ||= [];
+
+      # XXX do some kind of equivalent to set_exemptions here
+      # but for now just declare that there are no exemptions,
+      # and then hack the taxable amounts if the package def
+      # excludes setup/recur
+      $pkg->set('cust_tax_exempt_pkg', []);
+
+      if ( $part_item->setuptax or $tax_def->setuptax ) {
+        $pkg->set('unitsetup', 0);
+      }
+      if ( $part_item->recurtax or $tax_def->recurtax ) {
+        $pkg->set('unitrecur', 0);
+      }
+
+      my %taxline;
+      foreach my $pass (qw(first recur)) {
+        if ($pass eq 'recur') {
+          $pkg->set('unitsetup', 0);
+        }
+
+        my $taxline = $tax_def->taxline(
+          [ $pkg ],
+          exemptions => $taxnum_exemptions{$taxnum}
+        );
+        if ($taxline and !ref($taxline)) {
+          $dbh->rollback if $oldAutoCommit;
+          die "error calculating '".$tax_def->taxname .
+              "' for pkgpart '".$pkg->pkgpart."': $taxline\n";
+        }
+        $taxline{$pass} = $taxline;
+      }
+
+      my $quotation_pkg_tax = FS::quotation_pkg_tax->new({
+          quotationpkgnum => $pkg->quotationpkgnum,
+          itemdesc        => ($tax_def->taxname || 'Tax'),
+          taxnum          => $taxnum,
+          taxtype         => ref($tax_def),
+      });
+      my $setup_amount = 0;
+      my $recur_amount = 0;
+      if ($taxline{first}) {
+        $setup_amount = $taxline{first}->setup; # "first cycle", not setup
+      }
+      if ($taxline{recur}) {
+        $recur_amount = $taxline{recur}->setup;
+        $setup_amount -= $recur_amount; # to get the actual setup amount
+      }
+      if ( $recur_amount > 0 or $setup_amount > 0 ) {
+        $quotation_pkg_tax->set('setup_amount', sprintf('%.2f', $setup_amount));
+        $quotation_pkg_tax->set('recur_amount', sprintf('%.2f', $recur_amount));
+
+        my $error = $quotation_pkg_tax->insert;
+        if ($error) {
+          $dbh->rollback if $oldAutoCommit;
+          die "error recording '".$tax_def->taxname .
+              "' for pkgpart '".$pkg->pkgpart."': $error\n";
+        } # if $error
+      } # else there are no non-zero taxes; continue
+    } # foreach $tax_def
+  } # foreach $pkg
+
+  $dbh->commit if $oldAutoCommit;
+  '';
+}
+
 =back
 
 =head1 CLASS METHODS
 =back
 
 =head1 CLASS METHODS
@@ -673,15 +956,9 @@ first, and doesn't implement the "condensed" option.
 
 sub _items_pkg {
   my ($self, %options) = @_;
 
 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
   # 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
 }
 
 =back