2 use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::otaker_Mixin FS::Record );
8 use FS::Maketext qw( emt );
9 use FS::Record qw( qsearch qsearchs );
13 use FS::prospect_main;
14 use FS::quotation_pkg;
15 use FS::quotation_pkg_tax;
20 FS::quotation - Object methods for quotation records
26 $record = new FS::quotation \%hash;
27 $record = new FS::quotation { 'column' => 'value' };
29 $error = $record->insert;
31 $error = $new_record->replace($old_record);
33 $error = $record->delete;
35 $error = $record->check;
39 An FS::quotation object represents a quotation. FS::quotation inherits from
40 FS::Record. The following fields are currently supported:
70 projected date when the quotation will be closed
74 projected confidence (expressed as integer) that quotation will close
84 Creates a new quotation. To add the quotation to the database, see L<"insert">.
86 Note that this stores the hash reference, not a distinct copy of the hash it
87 points to. You can ask the object for a copy with the I<hash> method.
91 sub table { 'quotation'; }
92 sub notice_name { 'Quotation'; }
93 sub template_conf { 'quotation_'; }
97 Adds this record to the database. If there is an error, returns the error,
98 otherwise returns false.
102 Delete this record from the database.
104 =item replace OLD_RECORD
106 Replaces the OLD_RECORD with this one in the database. If there is an error,
107 returns the error, otherwise returns false.
111 Checks all fields to make sure this is a valid quotation. If there is
112 an error, returns the error, otherwise returns false. Called by the insert
121 $self->ut_numbern('quotationnum')
122 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum' )
123 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum' )
124 || $self->ut_numbern('_date')
125 || $self->ut_enum('disabled', [ '', 'Y' ])
126 || $self->ut_numbern('usernum')
127 || $self->ut_numbern('close_date')
128 || $self->ut_numbern('confidence')
130 return $error if $error;
132 $self->_date(time) unless $self->_date;
134 $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
136 return 'confidence must be an integer between 1 and 100'
137 if length($self->confidence) && (($self->confidence < 1) || ($self->confidence > 100));
139 return 'prospectnum or custnum must be specified'
140 if ! $self->prospectnum
152 qsearchs('prospect_main', { 'prospectnum' => $self->prospectnum } );
161 qsearchs('cust_main', { 'custnum' => $self->custnum } );
168 sub cust_bill_pkg { #actually quotation_pkg objects
170 qsearch('quotation_pkg', { quotationnum=>$self->quotationnum });
179 $self->_total('setup');
182 =item total_recur [ FREQ ]
188 #=item total_recur [ FREQ ]
189 #my $freq = @_ ? shift : '';
190 $self->_total('recur');
194 my( $self, $method ) = @_;
197 $total += $_->$method() for $self->cust_bill_pkg;
198 sprintf('%.2f', $total);
204 my $opt = shift || {};
205 if ($opt and !ref($opt)) {
206 die ref($self). '->email called with positional parameters';
209 my $conf = $self->conf;
211 my $from = delete $opt->{from};
213 # this is where we set the From: address
214 $from ||= $conf->config('quotation_from', $self->cust_or_prospect->agentnum )
215 || $conf->invoice_from_full( $self->cust_or_prospect->agentnum );
216 $self->SUPER::email( {
227 $self->conf->config('quotation_subject') #, $self->cust_main->agentnum)
230 #my $cust_main = $self->cust_main;
231 #my $name = $cust_main->name;
232 #my $name_short = $cust_main->name_short;
233 #my $invoice_number = $self->invnum;
234 #my $invoice_date = $self->_date_pretty;
239 =item cust_or_prosect
243 sub cust_or_prospect {
245 $self->custnum ? $self->cust_main : $self->prospect_main;
248 =item cust_or_prospect_label_link P
250 HTML links to either the customer or prospect.
252 Returns a list consisting of two elements. The first is a text label for the
253 link, and the second is the URL.
257 sub cust_or_prospect_label_link {
258 my( $self, $p ) = @_;
260 if ( my $custnum = $self->custnum ) {
261 my $display_custnum = $self->cust_main->display_custnum;
262 my $target = $FS::CurrentUser::CurrentUser->default_customer_view eq 'jumbo'
264 : ';show=quotations';
266 emt("View this customer (#[_1])",$display_custnum) =>
267 "${p}view/cust_main.cgi?custnum=$custnum$target"
269 } elsif ( my $prospectnum = $self->prospectnum ) {
271 emt("View this prospect (#[_1])",$prospectnum) =>
272 "${p}view/prospect_main.html?$prospectnum"
285 shift->cust_bill_pkg;
290 $self->quotationnum =~ /^(\d+)$/ or return ();
294 # show taxes in here also; the setup/recurring breakdown is different
295 # from what Template_Mixin expects
296 my @setup_tax = qsearch({
297 select => 'itemdesc, SUM(setup_amount) as setup_amount',
298 table => 'quotation_pkg_tax',
299 addl_from => ' JOIN quotation_pkg USING (quotationpkgnum) ',
300 extra_sql => ' WHERE quotationnum = '.$1,
301 order_by => ' GROUP BY itemdesc HAVING SUM(setup_amount) > 0' .
302 ' ORDER BY itemdesc',
304 # recurs need to be grouped by frequency, and to have a pkgpart
305 my @recur_tax = qsearch({
306 select => 'freq, itemdesc, SUM(recur_amount) as recur_amount, MAX(pkgpart) as pkgpart',
307 table => 'quotation_pkg_tax',
308 addl_from => ' JOIN quotation_pkg USING (quotationpkgnum)'.
309 ' JOIN part_pkg USING (pkgpart)',
310 extra_sql => ' WHERE quotationnum = '.$1,
311 order_by => ' GROUP BY freq, itemdesc HAVING SUM(recur_amount) > 0' .
312 ' ORDER BY freq, itemdesc',
315 my $total_setup = $self->total_setup;
316 my $total_recur = $self->total_recur;
317 my $setup_show = $total_setup > 0 ? 1 : 0;
318 my $recur_show = $total_recur > 0 ? 1 : 0;
319 unless ($setup_show && $recur_show) {
320 foreach my $quotation_pkg ($self->quotation_pkg) {
321 $setup_show = 1 if !$setup_show and $quotation_pkg->setup_show_zero;
322 $recur_show = 1 if !$recur_show and $quotation_pkg->recur_show_zero;
323 last if $setup_show && $recur_show;
327 foreach my $pkg_tax (@setup_tax) {
328 if ($pkg_tax->setup_amount > 0) {
329 $total_setup += $pkg_tax->setup_amount;
331 'total_item' => $pkg_tax->itemdesc . ' ' . $self->mt('(setup)'),
332 'total_amount' => $pkg_tax->setup_amount,
339 'total_item' => $self->mt( $recur_show ? 'Total Setup' : 'Total' ),
340 'total_amount' => sprintf('%.2f',$total_setup),
341 'break_after' => ( scalar(@recur_tax) ? 1 : 0 )
345 #could/should add up the different recurring frequencies on lines of their own
346 # but this will cover the 95% cases for now
347 # label these with the frequency
348 foreach my $pkg_tax (@recur_tax) {
349 if ($pkg_tax->recur_amount > 0) {
350 $total_recur += $pkg_tax->recur_amount;
351 # an arbitrary part_pkg, but with the right frequency
353 my $part_pkg = qsearchs('part_pkg', { pkgpart => $pkg_tax->pkgpart });
355 'total_item' => $pkg_tax->itemdesc . ' (' . $part_pkg->freq_pretty . ')',
356 'total_amount' => $pkg_tax->recur_amount,
363 'total_item' => $self->mt('Total Recurring'),
364 'total_amount' => sprintf('%.2f',$total_recur),
367 # show 'first payment' line (setup + recur) if there are no prorated
369 my $disable_total = 0;
370 foreach my $quotation_pkg ($self->quotation_pkg) {
371 my $part_pkg = $quotation_pkg->part_pkg;
372 if ( $part_pkg->plan =~ /^(prorate|torrus|agent$)/
373 || $part_pkg->option('recur_method') eq 'prorate'
374 || ( $part_pkg->option('sync_bill_date')
376 && $self->cust_main->billing_pkgs #num_billing_pkgs when we have it
383 if (!$disable_total) {
385 'total_item' => $self->mt('First payment'),
386 'total_amount' => sprintf('%.2f', $total_setup + $total_recur),
396 =item enable_previous
400 sub enable_previous { 0 }
402 =item convert_cust_main [ PARAMS ]
404 If this quotation already belongs to a customer, then returns that customer, as
405 an FS::cust_main object.
407 Otherwise, creates a new customer (FS::cust_main object and record, and
408 associated) based on this quotation's prospect, then orders this quotation's
409 packages as real packages for the customer.
411 If there is an error, returns an error message, otherwise, returns the
412 newly-created FS::cust_main object.
414 Accepts the same params as L</order>.
418 sub convert_cust_main {
420 my $params = shift || {};
422 my $cust_main = $self->cust_main;
423 return $cust_main if $cust_main; #already converted, don't again
425 my $oldAutoCommit = $FS::UID::AutoCommit;
426 local $FS::UID::AutoCommit = 0;
429 $cust_main = $self->prospect_main->convert_cust_main;
430 unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
431 $dbh->rollback if $oldAutoCommit;
435 $self->prospectnum('');
436 $self->custnum( $cust_main->custnum );
437 my $error = $self->replace || $self->order(undef,$params);
439 $dbh->rollback if $oldAutoCommit;
443 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
449 =item order [ HASHREF ] [ PARAMS ]
451 This method is for use with quotations which are already associated with a customer.
453 Orders this quotation's packages as real packages for the customer.
455 If there is an error, returns an error message, otherwise returns false.
457 If HASHREF is passed, it will be filled with a hash mapping the
458 C<quotationpkgnum> of each quoted package to the C<pkgnum> of the package
461 If PARAMS hashref is passed, the following params are accepted:
463 onhold - if true, suspends newly ordered packages
469 my $pkgnum_map = shift || {};
470 my $params = shift || {};
471 my $details_map = {};
473 tie my %all_cust_pkg, 'Tie::RefHash';
474 foreach my $quotation_pkg ($self->quotation_pkg) {
475 my $cust_pkg = FS::cust_pkg->new;
476 $pkgnum_map->{ $quotation_pkg->quotationpkgnum } = $cust_pkg;
478 # details will be copied below, after package is ordered
479 $details_map->{ $quotation_pkg->quotationpkgnum } = [
480 map { $_->copy_on_order ? $_->detail : () } $quotation_pkg->quotation_pkg_detail
483 foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
484 $cust_pkg->set( $_, $quotation_pkg->get($_) );
487 # currently only one discount each
488 my ($pkg_discount) = $quotation_pkg->quotation_pkg_discount;
489 if ( $pkg_discount ) {
490 $cust_pkg->set('discountnum', $pkg_discount->discountnum);
493 $all_cust_pkg{$cust_pkg} = []; # no services
496 local $SIG{HUP} = 'IGNORE';
497 local $SIG{INT} = 'IGNORE';
498 local $SIG{QUIT} = 'IGNORE';
499 local $SIG{TERM} = 'IGNORE';
500 local $SIG{TSTP} = 'IGNORE';
501 local $SIG{PIPE} = 'IGNORE';
503 my $oldAutoCommit = $FS::UID::AutoCommit;
504 local $FS::UID::AutoCommit = 0;
507 my $error = $self->cust_main->order_pkgs( \%all_cust_pkg );
510 # copy details (copy_on_order filtering handled above)
511 foreach my $quotationpkgnum (keys %$details_map) {
512 next unless @{$details_map->{$quotationpkgnum}};
513 $error = $pkgnum_map->{$quotationpkgnum}->set_cust_pkg_detail(
515 @{$details_map->{$quotationpkgnum}}
521 if ($$params{'onhold'}) {
522 foreach my $quotationpkgnum (keys %$pkgnum_map) {
524 $error = $pkgnum_map->{$quotationpkgnum}->suspend();
529 $dbh->rollback if $oldAutoCommit;
533 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
535 foreach my $quotationpkgnum (keys %$pkgnum_map) {
536 # convert the objects to just pkgnums
537 my $cust_pkg = $pkgnum_map->{$quotationpkgnum};
538 $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum;
551 qsearch('quotation_pkg', { 'quotationnum' => $self->quotationnum } );
556 One-time charges, like FS::cust_main::charge()
560 #super false laziness w/cust_main::charge
563 my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
564 my ( $pkg, $comment, $additional );
565 my ( $setuptax, $taxclass ); #internal taxes
566 my ( $taxproduct, $override ); #vendor (CCH) taxes
568 my $cust_pkg_ref = '';
569 my ( $bill_now, $invoice_terms ) = ( 0, '' );
571 if ( ref( $_[0] ) ) {
572 $amount = $_[0]->{amount};
573 $setup_cost = $_[0]->{setup_cost};
574 $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
575 $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
576 $no_auto = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
577 $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
578 $comment = exists($_[0]->{comment}) ? $_[0]->{comment}
579 : '$'. sprintf("%.2f",$amount);
580 $setuptax = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
581 $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
582 $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
583 $additional = $_[0]->{additional} || [];
584 $taxproduct = $_[0]->{taxproductnum};
585 $override = { '' => $_[0]->{tax_override} };
586 $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
587 $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
588 $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
589 $locationnum = $_[0]->{locationnum};
595 $pkg = @_ ? shift : 'One-time charge';
596 $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
598 $taxclass = @_ ? shift : '';
602 local $SIG{HUP} = 'IGNORE';
603 local $SIG{INT} = 'IGNORE';
604 local $SIG{QUIT} = 'IGNORE';
605 local $SIG{TERM} = 'IGNORE';
606 local $SIG{TSTP} = 'IGNORE';
607 local $SIG{PIPE} = 'IGNORE';
609 my $oldAutoCommit = $FS::UID::AutoCommit;
610 local $FS::UID::AutoCommit = 0;
613 my $part_pkg = new FS::part_pkg ( {
615 'comment' => $comment,
619 'classnum' => ( $classnum ? $classnum : '' ),
620 'setuptax' => $setuptax,
621 'taxclass' => $taxclass,
622 'taxproductnum' => $taxproduct,
623 'setup_cost' => $setup_cost,
626 my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
627 ( 0 .. @$additional - 1 )
629 'additional_count' => scalar(@$additional),
630 'setup_fee' => $amount,
633 my $error = $part_pkg->insert( options => \%options,
634 tax_overrides => $override,
637 $dbh->rollback if $oldAutoCommit;
641 my $pkgpart = $part_pkg->pkgpart;
644 my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
646 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
647 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
648 $error = $type_pkgs->insert;
650 $dbh->rollback if $oldAutoCommit;
655 #except for DIFF, eveything above is idential to cust_main version
656 #but below is our own thing pretty much (adding a quotation package instead
657 # of ordering a customer package, no "bill now")
659 my $quotation_pkg = new FS::quotation_pkg ( {
660 'quotationnum' => $self->quotationnum,
661 'pkgpart' => $pkgpart,
662 'quantity' => $quantity,
663 #'start_date' => $start_date,
664 #'no_auto' => $no_auto,
665 'locationnum'=> $locationnum,
668 $error = $quotation_pkg->insert;
670 $dbh->rollback if $oldAutoCommit;
672 #} elsif ( $cust_pkg_ref ) {
673 # ${$cust_pkg_ref} = $cust_pkg;
676 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
683 Disables this quotation (sets disabled to Y, which hides the quotation on
684 prospects and customers).
686 If there is an error, returns an error message, otherwise returns false.
692 $self->disabled('Y');
698 Enables this quotation.
700 If there is an error, returns an error message, otherwise returns false.
712 Calculates current prices for all items on this quotation, including
713 discounts and taxes, and updates the quotation_pkg records accordingly.
719 my $conf = FS::Conf->new;
722 my $oldAutoCommit = $FS::UID::AutoCommit;
723 local $FS::UID::AutoCommit = 0;
725 # bring individual items up to date (set setup/recur and discounts)
726 my @quotation_pkg = $self->quotation_pkg;
727 foreach my $pkg (@quotation_pkg) {
728 my $error = $pkg->estimate;
730 $dbh->rollback if $oldAutoCommit;
731 die "error calculating estimate for pkgpart " . $pkg->pkgpart.": $error\n";
734 # delete old tax records
735 foreach my $quotation_pkg_tax ($pkg->quotation_pkg_tax) {
736 $error = $quotation_pkg_tax->delete;
738 $dbh->rollback if $oldAutoCommit;
739 die "error flushing tax records for pkgpart ". $pkg->pkgpart.": $error\n";
744 # annoyingly duplicates handle_taxes--fix this in 4.x
745 if ( $conf->exists('enable_taxproducts') ) {
746 warn "can't calculate external taxes for quotations yet\n";
751 my %taxnum_exemptions; # for monthly exemptions; as yet unused
753 foreach my $pkg (@quotation_pkg) {
754 my $location = $pkg->cust_location;
756 my $part_item = $pkg->part_pkg; # we don't have fees on these yet
757 my @loc_keys = qw( district city county state country);
758 my %taxhash = map { $_ => $location->$_ } @loc_keys;
759 $taxhash{'taxclass'} = $part_item->taxclass;
761 my %taxhash_elim = %taxhash;
762 my @elim = qw( district city county state );
764 @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
765 if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
766 #then try a match without taxclass
767 my %no_taxclass = %taxhash_elim;
768 $no_taxclass{ 'taxclass' } = '';
769 @taxes = qsearch( 'cust_main_county', \%no_taxclass );
772 $taxhash_elim{ shift(@elim) } = '';
773 } while ( !scalar(@taxes) && scalar(@elim) );
775 foreach my $tax_def (@taxes) {
776 my $taxnum = $tax_def->taxnum;
777 $taxnum_exemptions{$taxnum} ||= [];
779 # XXX do some kind of equivalent to set_exemptions here
780 # but for now just declare that there are no exemptions,
781 # and then hack the taxable amounts if the package def
782 # excludes setup/recur
783 $pkg->set('cust_tax_exempt_pkg', []);
785 if ( $part_item->setuptax or $tax_def->setuptax ) {
786 $pkg->set('unitsetup', 0);
788 if ( $part_item->recurtax or $tax_def->recurtax ) {
789 $pkg->set('unitrecur', 0);
793 foreach my $pass (qw(first recur)) {
794 if ($pass eq 'recur') {
795 $pkg->set('unitsetup', 0);
798 my $taxline = $tax_def->taxline(
800 exemptions => $taxnum_exemptions{$taxnum}
802 if ($taxline and !ref($taxline)) {
803 $dbh->rollback if $oldAutoCommit;
804 die "error calculating '".$tax_def->taxname .
805 "' for pkgpart '".$pkg->pkgpart."': $taxline\n";
807 $taxline{$pass} = $taxline;
810 my $quotation_pkg_tax = FS::quotation_pkg_tax->new({
811 quotationpkgnum => $pkg->quotationpkgnum,
812 itemdesc => ($tax_def->taxname || 'Tax'),
814 taxtype => ref($tax_def),
816 my $setup_amount = 0;
817 my $recur_amount = 0;
818 if ($taxline{first}) {
819 $setup_amount = $taxline{first}->setup; # "first cycle", not setup
821 if ($taxline{recur}) {
822 $recur_amount = $taxline{recur}->setup;
823 $setup_amount -= $recur_amount; # to get the actual setup amount
825 if ( $recur_amount > 0 or $setup_amount > 0 ) {
826 $quotation_pkg_tax->set('setup_amount', sprintf('%.2f', $setup_amount));
827 $quotation_pkg_tax->set('recur_amount', sprintf('%.2f', $recur_amount));
829 my $error = $quotation_pkg_tax->insert;
831 $dbh->rollback if $oldAutoCommit;
832 die "error recording '".$tax_def->taxname .
833 "' for pkgpart '".$pkg->pkgpart."': $error\n";
835 } # else there are no non-zero taxes; continue
839 $dbh->commit if $oldAutoCommit;
850 =item search_sql_where HASHREF
852 Class method which returns an SQL WHERE fragment to search for parameters
853 specified in HASHREF. Valid parameters are
859 List reference of start date, end date, as UNIX timestamps.
869 List reference of charged limits (exclusive).
873 List reference of charged limits (exclusive).
877 flag, return open invoices only
881 flag, return net invoices only
889 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
893 sub search_sql_where {
894 my($class, $param) = @_;
896 # warn "$me search_sql_where called with params: \n".
897 # join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
903 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
904 push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
908 # if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
909 # push @search, "cust_main.refnum = $1";
913 if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
914 push @search, "quotation.prospectnum = $1";
918 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
919 push @search, "cust_bill.custnum = $1";
923 if ( $param->{_date} ) {
924 my($beginning, $ending) = @{$param->{_date}};
926 push @search, "quotation._date >= $beginning",
927 "quotation._date < $ending";
931 if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
932 push @search, "quotation.quotationnum >= $1";
934 if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
935 push @search, "quotation.quotationnum <= $1";
939 # if ( $param->{charged} ) {
940 # my @charged = ref($param->{charged})
941 # ? @{ $param->{charged} }
942 # : ($param->{charged});
944 # push @search, map { s/^charged/cust_bill.charged/; $_; }
948 my $owed_sql = FS::cust_bill->owed_sql;
951 push @search, "quotation._date < ". (time-86400*$param->{'days'})
954 #agent virtualization
955 my $curuser = $FS::CurrentUser::CurrentUser;
956 #false laziness w/search/quotation.html
957 push @search,' ( '. $curuser->agentnums_sql( table=>'prospect_main' ).
958 ' OR '. $curuser->agentnums_sql( table=>'cust_main' ).
961 join(' AND ', @search );
967 Return line item hashes for each package on this quotation. Differs from the
968 base L<FS::Template_Mixin> version in that it recalculates each quoted package
969 first, and doesn't implement the "condensed" option.
974 my ($self, %options) = @_;
976 # run it through the Template_Mixin engine
977 return $self->_items_cust_bill_pkg([ $self->quotation_pkg ], %options);
986 L<FS::Record>, schema.html from the base documentation.