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 'prospectnum or custnum must be specified'
137 if ! $self->prospectnum
149 qsearchs('prospect_main', { 'prospectnum' => $self->prospectnum } );
158 qsearchs('cust_main', { 'custnum' => $self->custnum } );
165 sub cust_bill_pkg { #actually quotation_pkg objects
167 qsearch('quotation_pkg', { quotationnum=>$self->quotationnum });
176 $self->_total('setup');
179 =item total_recur [ FREQ ]
185 #=item total_recur [ FREQ ]
186 #my $freq = @_ ? shift : '';
187 $self->_total('recur');
191 my( $self, $method ) = @_;
194 $total += $_->$method() for $self->cust_bill_pkg;
195 sprintf('%.2f', $total);
201 my $opt = shift || {};
202 if ($opt and !ref($opt)) {
203 die ref($self). '->email called with positional parameters';
206 my $conf = $self->conf;
208 my $from = delete $opt->{from};
210 # this is where we set the From: address
211 $from ||= $conf->config('quotation_from', $self->cust_or_prospect->agentnum )
212 || $conf->invoice_from_full( $self->cust_or_prospect->agentnum );
213 $self->SUPER::email( {
224 $self->conf->config('quotation_subject') #, $self->cust_main->agentnum)
227 #my $cust_main = $self->cust_main;
228 #my $name = $cust_main->name;
229 #my $name_short = $cust_main->name_short;
230 #my $invoice_number = $self->invnum;
231 #my $invoice_date = $self->_date_pretty;
236 =item cust_or_prosect
240 sub cust_or_prospect {
242 $self->custnum ? $self->cust_main : $self->prospect_main;
245 =item cust_or_prospect_label_link P
247 HTML links to either the customer or prospect.
249 Returns a list consisting of two elements. The first is a text label for the
250 link, and the second is the URL.
254 sub cust_or_prospect_label_link {
255 my( $self, $p ) = @_;
257 if ( my $custnum = $self->custnum ) {
258 my $display_custnum = $self->cust_main->display_custnum;
259 my $target = $FS::CurrentUser::CurrentUser->default_customer_view eq 'jumbo'
261 : ';show=quotations';
263 emt("View this customer (#[_1])",$display_custnum) =>
264 "${p}view/cust_main.cgi?custnum=$custnum$target"
266 } elsif ( my $prospectnum = $self->prospectnum ) {
268 emt("View this prospect (#[_1])",$prospectnum) =>
269 "${p}view/prospect_main.html?$prospectnum"
282 shift->cust_bill_pkg;
287 $self->quotationnum =~ /^(\d+)$/ or return ();
291 # show taxes in here also; the setup/recurring breakdown is different
292 # from what Template_Mixin expects
293 my @setup_tax = qsearch({
294 select => 'itemdesc, SUM(setup_amount) as setup_amount',
295 table => 'quotation_pkg_tax',
296 addl_from => ' JOIN quotation_pkg USING (quotationpkgnum) ',
297 extra_sql => ' WHERE quotationnum = '.$1,
298 order_by => ' GROUP BY itemdesc HAVING SUM(setup_amount) > 0' .
299 ' ORDER BY itemdesc',
301 # recurs need to be grouped by frequency, and to have a pkgpart
302 my @recur_tax = qsearch({
303 select => 'freq, itemdesc, SUM(recur_amount) as recur_amount, MAX(pkgpart) as pkgpart',
304 table => 'quotation_pkg_tax',
305 addl_from => ' JOIN quotation_pkg USING (quotationpkgnum)'.
306 ' JOIN part_pkg USING (pkgpart)',
307 extra_sql => ' WHERE quotationnum = '.$1,
308 order_by => ' GROUP BY freq, itemdesc HAVING SUM(recur_amount) > 0' .
309 ' ORDER BY freq, itemdesc',
312 my $total_setup = $self->total_setup;
313 foreach my $pkg_tax (@setup_tax) {
314 if ($pkg_tax->setup_amount > 0) {
315 $total_setup += $pkg_tax->setup_amount;
317 'total_item' => $pkg_tax->itemdesc . ' ' . $self->mt('(setup)'),
318 'total_amount' => $pkg_tax->setup_amount,
323 if ( $total_setup > 0 ) {
325 'total_item' => $self->mt( $self->total_recur > 0 ? 'Total Setup' : 'Total' ),
326 'total_amount' => sprintf('%.2f',$total_setup),
327 'break_after' => ( scalar(@recur_tax) ? 1 : 0 )
331 #could/should add up the different recurring frequencies on lines of their own
332 # but this will cover the 95% cases for now
333 my $total_recur = $self->total_recur;
334 # label these with the frequency
335 foreach my $pkg_tax (@recur_tax) {
336 if ($pkg_tax->recur_amount > 0) {
337 $total_recur += $pkg_tax->recur_amount;
338 # an arbitrary part_pkg, but with the right frequency
340 my $part_pkg = qsearchs('part_pkg', { pkgpart => $pkg_tax->pkgpart });
342 'total_item' => $pkg_tax->itemdesc . ' (' . $part_pkg->freq_pretty . ')',
343 'total_amount' => $pkg_tax->recur_amount,
348 if ( $total_recur > 0 ) {
350 'total_item' => $self->mt('Total Recurring'),
351 'total_amount' => sprintf('%.2f',$total_recur),
354 # show 'first payment' line (setup + recur) if there are no prorated
356 my $disable_total = 0;
357 foreach my $quotation_pkg ($self->quotation_pkg) {
358 my $part_pkg = $quotation_pkg->part_pkg;
359 if ( $part_pkg->plan =~ /^(prorate|torrus|agent$)/
360 || $part_pkg->option('recur_method') eq 'prorate'
361 || ( $part_pkg->option('sync_bill_date')
363 && $self->cust_main->billing_pkgs #num_billing_pkgs when we have it
370 if (!$disable_total) {
372 'total_item' => $self->mt('First payment'),
373 'total_amount' => sprintf('%.2f', $total_setup + $total_recur),
383 =item enable_previous
387 sub enable_previous { 0 }
389 =item convert_cust_main
391 If this quotation already belongs to a customer, then returns that customer, as
392 an FS::cust_main object.
394 Otherwise, creates a new customer (FS::cust_main object and record, and
395 associated) based on this quotation's prospect, then orders this quotation's
396 packages as real packages for the customer.
398 If there is an error, returns an error message, otherwise, returns the
399 newly-created FS::cust_main object.
403 sub convert_cust_main {
406 my $cust_main = $self->cust_main;
407 return $cust_main if $cust_main; #already converted, don't again
409 my $oldAutoCommit = $FS::UID::AutoCommit;
410 local $FS::UID::AutoCommit = 0;
413 $cust_main = $self->prospect_main->convert_cust_main;
414 unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
415 $dbh->rollback if $oldAutoCommit;
419 $self->prospectnum('');
420 $self->custnum( $cust_main->custnum );
421 my $error = $self->replace || $self->order;
423 $dbh->rollback if $oldAutoCommit;
427 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
433 =item order [ HASHREF ]
435 This method is for use with quotations which are already associated with a customer.
437 Orders this quotation's packages as real packages for the customer.
439 If there is an error, returns an error message, otherwise returns false.
441 If HASHREF is passed, it will be filled with a hash mapping the
442 C<quotationpkgnum> of each quoted package to the C<pkgnum> of the package
449 my $pkgnum_map = shift || {};
450 my $details_map = {};
452 tie my %all_cust_pkg, 'Tie::RefHash';
453 foreach my $quotation_pkg ($self->quotation_pkg) {
454 my $cust_pkg = FS::cust_pkg->new;
455 $pkgnum_map->{ $quotation_pkg->quotationpkgnum } = $cust_pkg;
457 # details will be copied below, after package is ordered
458 $details_map->{ $quotation_pkg->quotationpkgnum } = [
459 map { $_->copy_on_order ? $_->detail : () } $quotation_pkg->quotation_pkg_detail
462 foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
463 $cust_pkg->set( $_, $quotation_pkg->get($_) );
466 # currently only one discount each
467 my ($pkg_discount) = $quotation_pkg->quotation_pkg_discount;
468 if ( $pkg_discount ) {
469 $cust_pkg->set('discountnum', $pkg_discount->discountnum);
472 $all_cust_pkg{$cust_pkg} = []; # no services
475 local $SIG{HUP} = 'IGNORE';
476 local $SIG{INT} = 'IGNORE';
477 local $SIG{QUIT} = 'IGNORE';
478 local $SIG{TERM} = 'IGNORE';
479 local $SIG{TSTP} = 'IGNORE';
480 local $SIG{PIPE} = 'IGNORE';
482 my $oldAutoCommit = $FS::UID::AutoCommit;
483 local $FS::UID::AutoCommit = 0;
486 my $error = $self->cust_main->order_pkgs( \%all_cust_pkg );
489 # copy details (copy_on_order filtering handled above)
490 foreach my $quotationpkgnum (keys %$details_map) {
491 next unless @{$details_map->{$quotationpkgnum}};
492 $error = $pkgnum_map->{$quotationpkgnum}->set_cust_pkg_detail(
494 @{$details_map->{$quotationpkgnum}}
500 foreach my $quotationpkgnum (keys %$pkgnum_map) {
501 # convert the objects to just pkgnums
502 my $cust_pkg = $pkgnum_map->{$quotationpkgnum};
503 $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum;
507 $dbh->rollback if $oldAutoCommit;
511 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
522 qsearch('quotation_pkg', { 'quotationnum' => $self->quotationnum } );
527 One-time charges, like FS::cust_main::charge()
531 #super false laziness w/cust_main::charge
534 my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
535 my ( $pkg, $comment, $additional );
536 my ( $setuptax, $taxclass ); #internal taxes
537 my ( $taxproduct, $override ); #vendor (CCH) taxes
539 my $cust_pkg_ref = '';
540 my ( $bill_now, $invoice_terms ) = ( 0, '' );
542 if ( ref( $_[0] ) ) {
543 $amount = $_[0]->{amount};
544 $setup_cost = $_[0]->{setup_cost};
545 $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
546 $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
547 $no_auto = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
548 $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
549 $comment = exists($_[0]->{comment}) ? $_[0]->{comment}
550 : '$'. sprintf("%.2f",$amount);
551 $setuptax = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
552 $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
553 $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
554 $additional = $_[0]->{additional} || [];
555 $taxproduct = $_[0]->{taxproductnum};
556 $override = { '' => $_[0]->{tax_override} };
557 $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
558 $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
559 $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
560 $locationnum = $_[0]->{locationnum};
566 $pkg = @_ ? shift : 'One-time charge';
567 $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
569 $taxclass = @_ ? shift : '';
573 local $SIG{HUP} = 'IGNORE';
574 local $SIG{INT} = 'IGNORE';
575 local $SIG{QUIT} = 'IGNORE';
576 local $SIG{TERM} = 'IGNORE';
577 local $SIG{TSTP} = 'IGNORE';
578 local $SIG{PIPE} = 'IGNORE';
580 my $oldAutoCommit = $FS::UID::AutoCommit;
581 local $FS::UID::AutoCommit = 0;
584 my $part_pkg = new FS::part_pkg ( {
586 'comment' => $comment,
590 'classnum' => ( $classnum ? $classnum : '' ),
591 'setuptax' => $setuptax,
592 'taxclass' => $taxclass,
593 'taxproductnum' => $taxproduct,
594 'setup_cost' => $setup_cost,
597 my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
598 ( 0 .. @$additional - 1 )
600 'additional_count' => scalar(@$additional),
601 'setup_fee' => $amount,
604 my $error = $part_pkg->insert( options => \%options,
605 tax_overrides => $override,
608 $dbh->rollback if $oldAutoCommit;
612 my $pkgpart = $part_pkg->pkgpart;
615 my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
617 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
618 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
619 $error = $type_pkgs->insert;
621 $dbh->rollback if $oldAutoCommit;
626 #except for DIFF, eveything above is idential to cust_main version
627 #but below is our own thing pretty much (adding a quotation package instead
628 # of ordering a customer package, no "bill now")
630 my $quotation_pkg = new FS::quotation_pkg ( {
631 'quotationnum' => $self->quotationnum,
632 'pkgpart' => $pkgpart,
633 'quantity' => $quantity,
634 #'start_date' => $start_date,
635 #'no_auto' => $no_auto,
636 'locationnum'=> $locationnum,
639 $error = $quotation_pkg->insert;
641 $dbh->rollback if $oldAutoCommit;
643 #} elsif ( $cust_pkg_ref ) {
644 # ${$cust_pkg_ref} = $cust_pkg;
647 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
654 Disables this quotation (sets disabled to Y, which hides the quotation on
655 prospects and customers).
657 If there is an error, returns an error message, otherwise returns false.
663 $self->disabled('Y');
669 Enables this quotation.
671 If there is an error, returns an error message, otherwise returns false.
683 Calculates current prices for all items on this quotation, including
684 discounts and taxes, and updates the quotation_pkg records accordingly.
690 my $conf = FS::Conf->new;
693 my $oldAutoCommit = $FS::UID::AutoCommit;
694 local $FS::UID::AutoCommit = 0;
696 # bring individual items up to date (set setup/recur and discounts)
697 my @quotation_pkg = $self->quotation_pkg;
698 foreach my $pkg (@quotation_pkg) {
699 my $error = $pkg->estimate;
701 $dbh->rollback if $oldAutoCommit;
702 die "error calculating estimate for pkgpart " . $pkg->pkgpart.": $error\n";
705 # delete old tax records
706 foreach my $quotation_pkg_tax ($pkg->quotation_pkg_tax) {
707 $error = $quotation_pkg_tax->delete;
709 $dbh->rollback if $oldAutoCommit;
710 die "error flushing tax records for pkgpart ". $pkg->pkgpart.": $error\n";
715 # annoyingly duplicates handle_taxes--fix this in 4.x
716 if ( $conf->exists('enable_taxproducts') ) {
717 warn "can't calculate external taxes for quotations yet\n";
722 my %taxnum_exemptions; # for monthly exemptions; as yet unused
724 foreach my $pkg (@quotation_pkg) {
725 my $location = $pkg->cust_location;
727 my $part_item = $pkg->part_pkg; # we don't have fees on these yet
728 my @loc_keys = qw( district city county state country);
729 my %taxhash = map { $_ => $location->$_ } @loc_keys;
730 $taxhash{'taxclass'} = $part_item->taxclass;
732 my %taxhash_elim = %taxhash;
733 my @elim = qw( district city county state );
735 @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
736 if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
737 #then try a match without taxclass
738 my %no_taxclass = %taxhash_elim;
739 $no_taxclass{ 'taxclass' } = '';
740 @taxes = qsearch( 'cust_main_county', \%no_taxclass );
743 $taxhash_elim{ shift(@elim) } = '';
744 } while ( !scalar(@taxes) && scalar(@elim) );
746 foreach my $tax_def (@taxes) {
747 my $taxnum = $tax_def->taxnum;
748 $taxnum_exemptions{$taxnum} ||= [];
750 # XXX do some kind of equivalent to set_exemptions here
751 # but for now just declare that there are no exemptions,
752 # and then hack the taxable amounts if the package def
753 # excludes setup/recur
754 $pkg->set('cust_tax_exempt_pkg', []);
756 if ( $part_item->setuptax or $tax_def->setuptax ) {
757 $pkg->set('unitsetup', 0);
759 if ( $part_item->recurtax or $tax_def->recurtax ) {
760 $pkg->set('unitrecur', 0);
764 foreach my $pass (qw(first recur)) {
765 if ($pass eq 'recur') {
766 $pkg->set('unitsetup', 0);
769 my $taxline = $tax_def->taxline(
771 exemptions => $taxnum_exemptions{$taxnum}
773 if ($taxline and !ref($taxline)) {
774 $dbh->rollback if $oldAutoCommit;
775 die "error calculating '".$tax_def->taxname .
776 "' for pkgpart '".$pkg->pkgpart."': $taxline\n";
778 $taxline{$pass} = $taxline;
781 my $quotation_pkg_tax = FS::quotation_pkg_tax->new({
782 quotationpkgnum => $pkg->quotationpkgnum,
783 itemdesc => ($tax_def->taxname || 'Tax'),
785 taxtype => ref($tax_def),
787 my $setup_amount = 0;
788 my $recur_amount = 0;
789 if ($taxline{first}) {
790 $setup_amount = $taxline{first}->setup; # "first cycle", not setup
792 if ($taxline{recur}) {
793 $recur_amount = $taxline{recur}->setup;
794 $setup_amount -= $recur_amount; # to get the actual setup amount
796 if ( $recur_amount > 0 or $setup_amount > 0 ) {
797 $quotation_pkg_tax->set('setup_amount', sprintf('%.2f', $setup_amount));
798 $quotation_pkg_tax->set('recur_amount', sprintf('%.2f', $recur_amount));
800 my $error = $quotation_pkg_tax->insert;
802 $dbh->rollback if $oldAutoCommit;
803 die "error recording '".$tax_def->taxname .
804 "' for pkgpart '".$pkg->pkgpart."': $error\n";
806 } # else there are no non-zero taxes; continue
810 $dbh->commit if $oldAutoCommit;
821 =item search_sql_where HASHREF
823 Class method which returns an SQL WHERE fragment to search for parameters
824 specified in HASHREF. Valid parameters are
830 List reference of start date, end date, as UNIX timestamps.
840 List reference of charged limits (exclusive).
844 List reference of charged limits (exclusive).
848 flag, return open invoices only
852 flag, return net invoices only
860 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
864 sub search_sql_where {
865 my($class, $param) = @_;
867 # warn "$me search_sql_where called with params: \n".
868 # join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
874 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
875 push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
879 # if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
880 # push @search, "cust_main.refnum = $1";
884 if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
885 push @search, "quotation.prospectnum = $1";
889 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
890 push @search, "cust_bill.custnum = $1";
894 if ( $param->{_date} ) {
895 my($beginning, $ending) = @{$param->{_date}};
897 push @search, "quotation._date >= $beginning",
898 "quotation._date < $ending";
902 if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
903 push @search, "quotation.quotationnum >= $1";
905 if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
906 push @search, "quotation.quotationnum <= $1";
910 # if ( $param->{charged} ) {
911 # my @charged = ref($param->{charged})
912 # ? @{ $param->{charged} }
913 # : ($param->{charged});
915 # push @search, map { s/^charged/cust_bill.charged/; $_; }
919 my $owed_sql = FS::cust_bill->owed_sql;
922 push @search, "quotation._date < ". (time-86400*$param->{'days'})
925 #agent virtualization
926 my $curuser = $FS::CurrentUser::CurrentUser;
927 #false laziness w/search/quotation.html
928 push @search,' ( '. $curuser->agentnums_sql( table=>'prospect_main' ).
929 ' OR '. $curuser->agentnums_sql( table=>'cust_main' ).
932 join(' AND ', @search );
938 Return line item hashes for each package on this quotation. Differs from the
939 base L<FS::Template_Mixin> version in that it recalculates each quoted package
940 first, and doesn't implement the "condensed" option.
945 my ($self, %options) = @_;
947 # run it through the Template_Mixin engine
948 return $self->_items_cust_bill_pkg([ $self->quotation_pkg ], %options);
957 L<FS::Record>, schema.html from the base documentation.