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 foreach my $pkg_tax (@setup_tax) {
317 if ($pkg_tax->setup_amount > 0) {
318 $total_setup += $pkg_tax->setup_amount;
320 'total_item' => $pkg_tax->itemdesc . ' ' . $self->mt('(setup)'),
321 'total_amount' => $pkg_tax->setup_amount,
326 if ( $total_setup > 0 ) {
328 'total_item' => $self->mt( $self->total_recur > 0 ? 'Total Setup' : 'Total' ),
329 'total_amount' => sprintf('%.2f',$total_setup),
330 'break_after' => ( scalar(@recur_tax) ? 1 : 0 )
334 #could/should add up the different recurring frequencies on lines of their own
335 # but this will cover the 95% cases for now
336 my $total_recur = $self->total_recur;
337 # label these with the frequency
338 foreach my $pkg_tax (@recur_tax) {
339 if ($pkg_tax->recur_amount > 0) {
340 $total_recur += $pkg_tax->recur_amount;
341 # an arbitrary part_pkg, but with the right frequency
343 my $part_pkg = qsearchs('part_pkg', { pkgpart => $pkg_tax->pkgpart });
345 'total_item' => $pkg_tax->itemdesc . ' (' . $part_pkg->freq_pretty . ')',
346 'total_amount' => $pkg_tax->recur_amount,
351 if ( $total_recur > 0 ) {
353 'total_item' => $self->mt('Total Recurring'),
354 'total_amount' => sprintf('%.2f',$total_recur),
357 # show 'first payment' line (setup + recur) if there are no prorated
359 my $disable_total = 0;
360 foreach my $quotation_pkg ($self->quotation_pkg) {
361 my $part_pkg = $quotation_pkg->part_pkg;
362 if ( $part_pkg->plan =~ /^(prorate|torrus|agent$)/
363 || $part_pkg->option('recur_method') eq 'prorate'
364 || ( $part_pkg->option('sync_bill_date')
366 && $self->cust_main->billing_pkgs #num_billing_pkgs when we have it
373 if (!$disable_total) {
375 'total_item' => $self->mt('First payment'),
376 'total_amount' => sprintf('%.2f', $total_setup + $total_recur),
386 =item enable_previous
390 sub enable_previous { 0 }
392 =item convert_cust_main
394 If this quotation already belongs to a customer, then returns that customer, as
395 an FS::cust_main object.
397 Otherwise, creates a new customer (FS::cust_main object and record, and
398 associated) based on this quotation's prospect, then orders this quotation's
399 packages as real packages for the customer.
401 If there is an error, returns an error message, otherwise, returns the
402 newly-created FS::cust_main object.
406 sub convert_cust_main {
409 my $cust_main = $self->cust_main;
410 return $cust_main if $cust_main; #already converted, don't again
412 my $oldAutoCommit = $FS::UID::AutoCommit;
413 local $FS::UID::AutoCommit = 0;
416 $cust_main = $self->prospect_main->convert_cust_main;
417 unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
418 $dbh->rollback if $oldAutoCommit;
422 $self->prospectnum('');
423 $self->custnum( $cust_main->custnum );
424 my $error = $self->replace || $self->order;
426 $dbh->rollback if $oldAutoCommit;
430 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
436 =item order [ HASHREF ]
438 This method is for use with quotations which are already associated with a customer.
440 Orders this quotation's packages as real packages for the customer.
442 If there is an error, returns an error message, otherwise returns false.
444 If HASHREF is passed, it will be filled with a hash mapping the
445 C<quotationpkgnum> of each quoted package to the C<pkgnum> of the package
452 my $pkgnum_map = shift || {};
453 my $details_map = {};
455 tie my %all_cust_pkg, 'Tie::RefHash';
456 foreach my $quotation_pkg ($self->quotation_pkg) {
457 my $cust_pkg = FS::cust_pkg->new;
458 $pkgnum_map->{ $quotation_pkg->quotationpkgnum } = $cust_pkg;
460 # details will be copied below, after package is ordered
461 $details_map->{ $quotation_pkg->quotationpkgnum } = [
462 map { $_->copy_on_order ? $_->detail : () } $quotation_pkg->quotation_pkg_detail
465 foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
466 $cust_pkg->set( $_, $quotation_pkg->get($_) );
469 # currently only one discount each
470 my ($pkg_discount) = $quotation_pkg->quotation_pkg_discount;
471 if ( $pkg_discount ) {
472 $cust_pkg->set('discountnum', $pkg_discount->discountnum);
475 $all_cust_pkg{$cust_pkg} = []; # no services
478 local $SIG{HUP} = 'IGNORE';
479 local $SIG{INT} = 'IGNORE';
480 local $SIG{QUIT} = 'IGNORE';
481 local $SIG{TERM} = 'IGNORE';
482 local $SIG{TSTP} = 'IGNORE';
483 local $SIG{PIPE} = 'IGNORE';
485 my $oldAutoCommit = $FS::UID::AutoCommit;
486 local $FS::UID::AutoCommit = 0;
489 my $error = $self->cust_main->order_pkgs( \%all_cust_pkg );
492 # copy details (copy_on_order filtering handled above)
493 foreach my $quotationpkgnum (keys %$details_map) {
494 next unless @{$details_map->{$quotationpkgnum}};
495 $error = $pkgnum_map->{$quotationpkgnum}->set_cust_pkg_detail(
497 @{$details_map->{$quotationpkgnum}}
503 foreach my $quotationpkgnum (keys %$pkgnum_map) {
504 # convert the objects to just pkgnums
505 my $cust_pkg = $pkgnum_map->{$quotationpkgnum};
506 $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum;
510 $dbh->rollback if $oldAutoCommit;
514 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
525 qsearch('quotation_pkg', { 'quotationnum' => $self->quotationnum } );
530 One-time charges, like FS::cust_main::charge()
534 #super false laziness w/cust_main::charge
537 my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
538 my ( $pkg, $comment, $additional );
539 my ( $setuptax, $taxclass ); #internal taxes
540 my ( $taxproduct, $override ); #vendor (CCH) taxes
542 my $cust_pkg_ref = '';
543 my ( $bill_now, $invoice_terms ) = ( 0, '' );
545 if ( ref( $_[0] ) ) {
546 $amount = $_[0]->{amount};
547 $setup_cost = $_[0]->{setup_cost};
548 $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
549 $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
550 $no_auto = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
551 $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
552 $comment = exists($_[0]->{comment}) ? $_[0]->{comment}
553 : '$'. sprintf("%.2f",$amount);
554 $setuptax = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
555 $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
556 $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
557 $additional = $_[0]->{additional} || [];
558 $taxproduct = $_[0]->{taxproductnum};
559 $override = { '' => $_[0]->{tax_override} };
560 $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
561 $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
562 $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
563 $locationnum = $_[0]->{locationnum};
569 $pkg = @_ ? shift : 'One-time charge';
570 $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
572 $taxclass = @_ ? shift : '';
576 local $SIG{HUP} = 'IGNORE';
577 local $SIG{INT} = 'IGNORE';
578 local $SIG{QUIT} = 'IGNORE';
579 local $SIG{TERM} = 'IGNORE';
580 local $SIG{TSTP} = 'IGNORE';
581 local $SIG{PIPE} = 'IGNORE';
583 my $oldAutoCommit = $FS::UID::AutoCommit;
584 local $FS::UID::AutoCommit = 0;
587 my $part_pkg = new FS::part_pkg ( {
589 'comment' => $comment,
593 'classnum' => ( $classnum ? $classnum : '' ),
594 'setuptax' => $setuptax,
595 'taxclass' => $taxclass,
596 'taxproductnum' => $taxproduct,
597 'setup_cost' => $setup_cost,
600 my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
601 ( 0 .. @$additional - 1 )
603 'additional_count' => scalar(@$additional),
604 'setup_fee' => $amount,
607 my $error = $part_pkg->insert( options => \%options,
608 tax_overrides => $override,
611 $dbh->rollback if $oldAutoCommit;
615 my $pkgpart = $part_pkg->pkgpart;
618 my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
620 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
621 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
622 $error = $type_pkgs->insert;
624 $dbh->rollback if $oldAutoCommit;
629 #except for DIFF, eveything above is idential to cust_main version
630 #but below is our own thing pretty much (adding a quotation package instead
631 # of ordering a customer package, no "bill now")
633 my $quotation_pkg = new FS::quotation_pkg ( {
634 'quotationnum' => $self->quotationnum,
635 'pkgpart' => $pkgpart,
636 'quantity' => $quantity,
637 #'start_date' => $start_date,
638 #'no_auto' => $no_auto,
639 'locationnum'=> $locationnum,
642 $error = $quotation_pkg->insert;
644 $dbh->rollback if $oldAutoCommit;
646 #} elsif ( $cust_pkg_ref ) {
647 # ${$cust_pkg_ref} = $cust_pkg;
650 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
657 Disables this quotation (sets disabled to Y, which hides the quotation on
658 prospects and customers).
660 If there is an error, returns an error message, otherwise returns false.
666 $self->disabled('Y');
672 Enables this quotation.
674 If there is an error, returns an error message, otherwise returns false.
686 Calculates current prices for all items on this quotation, including
687 discounts and taxes, and updates the quotation_pkg records accordingly.
693 my $conf = FS::Conf->new;
696 my $oldAutoCommit = $FS::UID::AutoCommit;
697 local $FS::UID::AutoCommit = 0;
699 # bring individual items up to date (set setup/recur and discounts)
700 my @quotation_pkg = $self->quotation_pkg;
701 foreach my $pkg (@quotation_pkg) {
702 my $error = $pkg->estimate;
704 $dbh->rollback if $oldAutoCommit;
705 die "error calculating estimate for pkgpart " . $pkg->pkgpart.": $error\n";
708 # delete old tax records
709 foreach my $quotation_pkg_tax ($pkg->quotation_pkg_tax) {
710 $error = $quotation_pkg_tax->delete;
712 $dbh->rollback if $oldAutoCommit;
713 die "error flushing tax records for pkgpart ". $pkg->pkgpart.": $error\n";
718 # annoyingly duplicates handle_taxes--fix this in 4.x
719 if ( $conf->exists('enable_taxproducts') ) {
720 warn "can't calculate external taxes for quotations yet\n";
725 my %taxnum_exemptions; # for monthly exemptions; as yet unused
727 foreach my $pkg (@quotation_pkg) {
728 my $location = $pkg->cust_location;
730 my $part_item = $pkg->part_pkg; # we don't have fees on these yet
731 my @loc_keys = qw( district city county state country);
732 my %taxhash = map { $_ => $location->$_ } @loc_keys;
733 $taxhash{'taxclass'} = $part_item->taxclass;
735 my %taxhash_elim = %taxhash;
736 my @elim = qw( district city county state );
738 @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
739 if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
740 #then try a match without taxclass
741 my %no_taxclass = %taxhash_elim;
742 $no_taxclass{ 'taxclass' } = '';
743 @taxes = qsearch( 'cust_main_county', \%no_taxclass );
746 $taxhash_elim{ shift(@elim) } = '';
747 } while ( !scalar(@taxes) && scalar(@elim) );
749 foreach my $tax_def (@taxes) {
750 my $taxnum = $tax_def->taxnum;
751 $taxnum_exemptions{$taxnum} ||= [];
753 # XXX do some kind of equivalent to set_exemptions here
754 # but for now just declare that there are no exemptions,
755 # and then hack the taxable amounts if the package def
756 # excludes setup/recur
757 $pkg->set('cust_tax_exempt_pkg', []);
759 if ( $part_item->setuptax or $tax_def->setuptax ) {
760 $pkg->set('unitsetup', 0);
762 if ( $part_item->recurtax or $tax_def->recurtax ) {
763 $pkg->set('unitrecur', 0);
767 foreach my $pass (qw(first recur)) {
768 if ($pass eq 'recur') {
769 $pkg->set('unitsetup', 0);
772 my $taxline = $tax_def->taxline(
774 exemptions => $taxnum_exemptions{$taxnum}
776 if ($taxline and !ref($taxline)) {
777 $dbh->rollback if $oldAutoCommit;
778 die "error calculating '".$tax_def->taxname .
779 "' for pkgpart '".$pkg->pkgpart."': $taxline\n";
781 $taxline{$pass} = $taxline;
784 my $quotation_pkg_tax = FS::quotation_pkg_tax->new({
785 quotationpkgnum => $pkg->quotationpkgnum,
786 itemdesc => ($tax_def->taxname || 'Tax'),
788 taxtype => ref($tax_def),
790 my $setup_amount = 0;
791 my $recur_amount = 0;
792 if ($taxline{first}) {
793 $setup_amount = $taxline{first}->setup; # "first cycle", not setup
795 if ($taxline{recur}) {
796 $recur_amount = $taxline{recur}->setup;
797 $setup_amount -= $recur_amount; # to get the actual setup amount
799 if ( $recur_amount > 0 or $setup_amount > 0 ) {
800 $quotation_pkg_tax->set('setup_amount', sprintf('%.2f', $setup_amount));
801 $quotation_pkg_tax->set('recur_amount', sprintf('%.2f', $recur_amount));
803 my $error = $quotation_pkg_tax->insert;
805 $dbh->rollback if $oldAutoCommit;
806 die "error recording '".$tax_def->taxname .
807 "' for pkgpart '".$pkg->pkgpart."': $error\n";
809 } # else there are no non-zero taxes; continue
813 $dbh->commit if $oldAutoCommit;
824 =item search_sql_where HASHREF
826 Class method which returns an SQL WHERE fragment to search for parameters
827 specified in HASHREF. Valid parameters are
833 List reference of start date, end date, as UNIX timestamps.
843 List reference of charged limits (exclusive).
847 List reference of charged limits (exclusive).
851 flag, return open invoices only
855 flag, return net invoices only
863 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
867 sub search_sql_where {
868 my($class, $param) = @_;
870 # warn "$me search_sql_where called with params: \n".
871 # join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
877 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
878 push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
882 # if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
883 # push @search, "cust_main.refnum = $1";
887 if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
888 push @search, "quotation.prospectnum = $1";
892 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
893 push @search, "cust_bill.custnum = $1";
897 if ( $param->{_date} ) {
898 my($beginning, $ending) = @{$param->{_date}};
900 push @search, "quotation._date >= $beginning",
901 "quotation._date < $ending";
905 if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
906 push @search, "quotation.quotationnum >= $1";
908 if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
909 push @search, "quotation.quotationnum <= $1";
913 # if ( $param->{charged} ) {
914 # my @charged = ref($param->{charged})
915 # ? @{ $param->{charged} }
916 # : ($param->{charged});
918 # push @search, map { s/^charged/cust_bill.charged/; $_; }
922 my $owed_sql = FS::cust_bill->owed_sql;
925 push @search, "quotation._date < ". (time-86400*$param->{'days'})
928 #agent virtualization
929 my $curuser = $FS::CurrentUser::CurrentUser;
930 #false laziness w/search/quotation.html
931 push @search,' ( '. $curuser->agentnums_sql( table=>'prospect_main' ).
932 ' OR '. $curuser->agentnums_sql( table=>'cust_main' ).
935 join(' AND ', @search );
941 Return line item hashes for each package on this quotation. Differs from the
942 base L<FS::Template_Mixin> version in that it recalculates each quoted package
943 first, and doesn't implement the "condensed" option.
948 my ($self, %options) = @_;
950 # run it through the Template_Mixin engine
951 return $self->_items_cust_bill_pkg([ $self->quotation_pkg ], %options);
960 L<FS::Record>, schema.html from the base documentation.