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
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.
416 sub convert_cust_main {
419 my $cust_main = $self->cust_main;
420 return $cust_main if $cust_main; #already converted, don't again
422 my $oldAutoCommit = $FS::UID::AutoCommit;
423 local $FS::UID::AutoCommit = 0;
426 $cust_main = $self->prospect_main->convert_cust_main;
427 unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
428 $dbh->rollback if $oldAutoCommit;
432 $self->prospectnum('');
433 $self->custnum( $cust_main->custnum );
434 my $error = $self->replace || $self->order;
436 $dbh->rollback if $oldAutoCommit;
440 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
446 =item order [ HASHREF ]
448 This method is for use with quotations which are already associated with a customer.
450 Orders this quotation's packages as real packages for the customer.
452 If there is an error, returns an error message, otherwise returns false.
454 If HASHREF is passed, it will be filled with a hash mapping the
455 C<quotationpkgnum> of each quoted package to the C<pkgnum> of the package
462 my $pkgnum_map = shift || {};
463 my $details_map = {};
465 tie my %all_cust_pkg, 'Tie::RefHash';
466 foreach my $quotation_pkg ($self->quotation_pkg) {
467 my $cust_pkg = FS::cust_pkg->new;
468 $pkgnum_map->{ $quotation_pkg->quotationpkgnum } = $cust_pkg;
470 # details will be copied below, after package is ordered
471 $details_map->{ $quotation_pkg->quotationpkgnum } = [
472 map { $_->copy_on_order ? $_->detail : () } $quotation_pkg->quotation_pkg_detail
475 foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
476 $cust_pkg->set( $_, $quotation_pkg->get($_) );
479 # currently only one discount each
480 my ($pkg_discount) = $quotation_pkg->quotation_pkg_discount;
481 if ( $pkg_discount ) {
482 $cust_pkg->set('discountnum', $pkg_discount->discountnum);
485 $all_cust_pkg{$cust_pkg} = []; # no services
488 local $SIG{HUP} = 'IGNORE';
489 local $SIG{INT} = 'IGNORE';
490 local $SIG{QUIT} = 'IGNORE';
491 local $SIG{TERM} = 'IGNORE';
492 local $SIG{TSTP} = 'IGNORE';
493 local $SIG{PIPE} = 'IGNORE';
495 my $oldAutoCommit = $FS::UID::AutoCommit;
496 local $FS::UID::AutoCommit = 0;
499 my $error = $self->cust_main->order_pkgs( \%all_cust_pkg );
502 # copy details (copy_on_order filtering handled above)
503 foreach my $quotationpkgnum (keys %$details_map) {
504 next unless @{$details_map->{$quotationpkgnum}};
505 $error = $pkgnum_map->{$quotationpkgnum}->set_cust_pkg_detail(
507 @{$details_map->{$quotationpkgnum}}
513 foreach my $quotationpkgnum (keys %$pkgnum_map) {
514 # convert the objects to just pkgnums
515 my $cust_pkg = $pkgnum_map->{$quotationpkgnum};
516 $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum;
520 $dbh->rollback if $oldAutoCommit;
524 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
535 qsearch('quotation_pkg', { 'quotationnum' => $self->quotationnum } );
540 One-time charges, like FS::cust_main::charge()
544 #super false laziness w/cust_main::charge
547 my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
548 my ( $pkg, $comment, $additional );
549 my ( $setuptax, $taxclass ); #internal taxes
550 my ( $taxproduct, $override ); #vendor (CCH) taxes
552 my $cust_pkg_ref = '';
553 my ( $bill_now, $invoice_terms ) = ( 0, '' );
555 if ( ref( $_[0] ) ) {
556 $amount = $_[0]->{amount};
557 $setup_cost = $_[0]->{setup_cost};
558 $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
559 $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
560 $no_auto = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
561 $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
562 $comment = exists($_[0]->{comment}) ? $_[0]->{comment}
563 : '$'. sprintf("%.2f",$amount);
564 $setuptax = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
565 $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
566 $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
567 $additional = $_[0]->{additional} || [];
568 $taxproduct = $_[0]->{taxproductnum};
569 $override = { '' => $_[0]->{tax_override} };
570 $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
571 $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
572 $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
573 $locationnum = $_[0]->{locationnum};
579 $pkg = @_ ? shift : 'One-time charge';
580 $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
582 $taxclass = @_ ? shift : '';
586 local $SIG{HUP} = 'IGNORE';
587 local $SIG{INT} = 'IGNORE';
588 local $SIG{QUIT} = 'IGNORE';
589 local $SIG{TERM} = 'IGNORE';
590 local $SIG{TSTP} = 'IGNORE';
591 local $SIG{PIPE} = 'IGNORE';
593 my $oldAutoCommit = $FS::UID::AutoCommit;
594 local $FS::UID::AutoCommit = 0;
597 my $part_pkg = new FS::part_pkg ( {
599 'comment' => $comment,
603 'classnum' => ( $classnum ? $classnum : '' ),
604 'setuptax' => $setuptax,
605 'taxclass' => $taxclass,
606 'taxproductnum' => $taxproduct,
607 'setup_cost' => $setup_cost,
610 my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
611 ( 0 .. @$additional - 1 )
613 'additional_count' => scalar(@$additional),
614 'setup_fee' => $amount,
617 my $error = $part_pkg->insert( options => \%options,
618 tax_overrides => $override,
621 $dbh->rollback if $oldAutoCommit;
625 my $pkgpart = $part_pkg->pkgpart;
628 my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
630 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
631 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
632 $error = $type_pkgs->insert;
634 $dbh->rollback if $oldAutoCommit;
639 #except for DIFF, eveything above is idential to cust_main version
640 #but below is our own thing pretty much (adding a quotation package instead
641 # of ordering a customer package, no "bill now")
643 my $quotation_pkg = new FS::quotation_pkg ( {
644 'quotationnum' => $self->quotationnum,
645 'pkgpart' => $pkgpart,
646 'quantity' => $quantity,
647 #'start_date' => $start_date,
648 #'no_auto' => $no_auto,
649 'locationnum'=> $locationnum,
652 $error = $quotation_pkg->insert;
654 $dbh->rollback if $oldAutoCommit;
656 #} elsif ( $cust_pkg_ref ) {
657 # ${$cust_pkg_ref} = $cust_pkg;
660 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
667 Disables this quotation (sets disabled to Y, which hides the quotation on
668 prospects and customers).
670 If there is an error, returns an error message, otherwise returns false.
676 $self->disabled('Y');
682 Enables this quotation.
684 If there is an error, returns an error message, otherwise returns false.
696 Calculates current prices for all items on this quotation, including
697 discounts and taxes, and updates the quotation_pkg records accordingly.
703 my $conf = FS::Conf->new;
706 my $oldAutoCommit = $FS::UID::AutoCommit;
707 local $FS::UID::AutoCommit = 0;
709 # bring individual items up to date (set setup/recur and discounts)
710 my @quotation_pkg = $self->quotation_pkg;
711 foreach my $pkg (@quotation_pkg) {
712 my $error = $pkg->estimate;
714 $dbh->rollback if $oldAutoCommit;
715 die "error calculating estimate for pkgpart " . $pkg->pkgpart.": $error\n";
718 # delete old tax records
719 foreach my $quotation_pkg_tax ($pkg->quotation_pkg_tax) {
720 $error = $quotation_pkg_tax->delete;
722 $dbh->rollback if $oldAutoCommit;
723 die "error flushing tax records for pkgpart ". $pkg->pkgpart.": $error\n";
728 # annoyingly duplicates handle_taxes--fix this in 4.x
729 if ( $conf->exists('enable_taxproducts') ) {
730 warn "can't calculate external taxes for quotations yet\n";
735 my %taxnum_exemptions; # for monthly exemptions; as yet unused
737 foreach my $pkg (@quotation_pkg) {
738 my $location = $pkg->cust_location;
740 my $part_item = $pkg->part_pkg; # we don't have fees on these yet
741 my @loc_keys = qw( district city county state country);
742 my %taxhash = map { $_ => $location->$_ } @loc_keys;
743 $taxhash{'taxclass'} = $part_item->taxclass;
745 my %taxhash_elim = %taxhash;
746 my @elim = qw( district city county state );
748 @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
749 if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
750 #then try a match without taxclass
751 my %no_taxclass = %taxhash_elim;
752 $no_taxclass{ 'taxclass' } = '';
753 @taxes = qsearch( 'cust_main_county', \%no_taxclass );
756 $taxhash_elim{ shift(@elim) } = '';
757 } while ( !scalar(@taxes) && scalar(@elim) );
759 foreach my $tax_def (@taxes) {
760 my $taxnum = $tax_def->taxnum;
761 $taxnum_exemptions{$taxnum} ||= [];
763 # XXX do some kind of equivalent to set_exemptions here
764 # but for now just declare that there are no exemptions,
765 # and then hack the taxable amounts if the package def
766 # excludes setup/recur
767 $pkg->set('cust_tax_exempt_pkg', []);
769 if ( $part_item->setuptax or $tax_def->setuptax ) {
770 $pkg->set('unitsetup', 0);
772 if ( $part_item->recurtax or $tax_def->recurtax ) {
773 $pkg->set('unitrecur', 0);
777 foreach my $pass (qw(first recur)) {
778 if ($pass eq 'recur') {
779 $pkg->set('unitsetup', 0);
782 my $taxline = $tax_def->taxline(
784 exemptions => $taxnum_exemptions{$taxnum}
786 if ($taxline and !ref($taxline)) {
787 $dbh->rollback if $oldAutoCommit;
788 die "error calculating '".$tax_def->taxname .
789 "' for pkgpart '".$pkg->pkgpart."': $taxline\n";
791 $taxline{$pass} = $taxline;
794 my $quotation_pkg_tax = FS::quotation_pkg_tax->new({
795 quotationpkgnum => $pkg->quotationpkgnum,
796 itemdesc => ($tax_def->taxname || 'Tax'),
798 taxtype => ref($tax_def),
800 my $setup_amount = 0;
801 my $recur_amount = 0;
802 if ($taxline{first}) {
803 $setup_amount = $taxline{first}->setup; # "first cycle", not setup
805 if ($taxline{recur}) {
806 $recur_amount = $taxline{recur}->setup;
807 $setup_amount -= $recur_amount; # to get the actual setup amount
809 if ( $recur_amount > 0 or $setup_amount > 0 ) {
810 $quotation_pkg_tax->set('setup_amount', sprintf('%.2f', $setup_amount));
811 $quotation_pkg_tax->set('recur_amount', sprintf('%.2f', $recur_amount));
813 my $error = $quotation_pkg_tax->insert;
815 $dbh->rollback if $oldAutoCommit;
816 die "error recording '".$tax_def->taxname .
817 "' for pkgpart '".$pkg->pkgpart."': $error\n";
819 } # else there are no non-zero taxes; continue
823 $dbh->commit if $oldAutoCommit;
834 =item search_sql_where HASHREF
836 Class method which returns an SQL WHERE fragment to search for parameters
837 specified in HASHREF. Valid parameters are
843 List reference of start date, end date, as UNIX timestamps.
853 List reference of charged limits (exclusive).
857 List reference of charged limits (exclusive).
861 flag, return open invoices only
865 flag, return net invoices only
873 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
877 sub search_sql_where {
878 my($class, $param) = @_;
880 # warn "$me search_sql_where called with params: \n".
881 # join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
887 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
888 push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
892 # if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
893 # push @search, "cust_main.refnum = $1";
897 if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
898 push @search, "quotation.prospectnum = $1";
902 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
903 push @search, "cust_bill.custnum = $1";
907 if ( $param->{_date} ) {
908 my($beginning, $ending) = @{$param->{_date}};
910 push @search, "quotation._date >= $beginning",
911 "quotation._date < $ending";
915 if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
916 push @search, "quotation.quotationnum >= $1";
918 if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
919 push @search, "quotation.quotationnum <= $1";
923 # if ( $param->{charged} ) {
924 # my @charged = ref($param->{charged})
925 # ? @{ $param->{charged} }
926 # : ($param->{charged});
928 # push @search, map { s/^charged/cust_bill.charged/; $_; }
932 my $owed_sql = FS::cust_bill->owed_sql;
935 push @search, "quotation._date < ". (time-86400*$param->{'days'})
938 #agent virtualization
939 my $curuser = $FS::CurrentUser::CurrentUser;
940 #false laziness w/search/quotation.html
941 push @search,' ( '. $curuser->agentnums_sql( table=>'prospect_main' ).
942 ' OR '. $curuser->agentnums_sql( table=>'cust_main' ).
945 join(' AND ', @search );
951 Return line item hashes for each package on this quotation. Differs from the
952 base L<FS::Template_Mixin> version in that it recalculates each quoted package
953 first, and doesn't implement the "condensed" option.
958 my ($self, %options) = @_;
960 # run it through the Template_Mixin engine
961 return $self->_items_cust_bill_pkg([ $self->quotation_pkg ], %options);
970 L<FS::Record>, schema.html from the base documentation.