2 use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::otaker_Mixin FS::Record
8 use FS::UID qw( dbh myconnect );
9 use FS::Maketext qw( emt );
10 use FS::Record qw( qsearch qsearchs );
14 use FS::quotation_pkg;
15 use FS::quotation_pkg_tax;
23 FS::quotation - Object methods for quotation records
29 $record = new FS::quotation \%hash;
30 $record = new FS::quotation { 'column' => 'value' };
32 $error = $record->insert;
34 $error = $new_record->replace($old_record);
36 $error = $record->delete;
38 $error = $record->check;
42 An FS::quotation object represents a quotation. FS::quotation inherits from
43 FS::Record. The following fields are currently supported:
80 Creates a new quotation. To add the quotation to the database, see L<"insert">.
82 Note that this stores the hash reference, not a distinct copy of the hash it
83 points to. You can ask the object for a copy with the I<hash> method.
87 sub table { 'quotation'; }
88 sub notice_name { 'Quotation'; }
89 sub template_conf { 'quotation_'; }
93 Adds this record to the database. If there is an error, returns the error,
94 otherwise returns false.
98 Delete this record from the database.
100 =item replace OLD_RECORD
102 Replaces the OLD_RECORD with this one in the database. If there is an error,
103 returns the error, otherwise returns false.
107 Checks all fields to make sure this is a valid quotation. If there is
108 an error, returns the error, otherwise returns false. Called by the insert
117 $self->ut_numbern('quotationnum')
118 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum' )
119 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum' )
120 || $self->ut_numbern('_date')
121 || $self->ut_enum('disabled', [ '', 'Y' ])
122 || $self->ut_numbern('usernum')
124 return $error if $error;
126 $self->_date(time) unless $self->_date;
128 $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
130 return 'prospectnum or custnum must be specified'
131 if ! $self->prospectnum
145 sub cust_bill_pkg { #actually quotation_pkg objects
146 shift->quotation_pkg(@_);
155 $self->_total('setup');
158 =item total_recur [ FREQ ]
164 #=item total_recur [ FREQ ]
165 #my $freq = @_ ? shift : '';
166 $self->_total('recur');
170 my( $self, $method ) = @_;
173 $total += $_->$method() for $self->cust_bill_pkg;
174 sprintf('%.2f', $total);
180 my $opt = shift || {};
181 if ($opt and !ref($opt)) {
182 die ref($self). '->email called with positional parameters';
185 my $conf = $self->conf;
187 my $from = delete $opt->{from};
189 # this is where we set the From: address
190 $from ||= $conf->config('quotation_from', $self->cust_or_prospect->agentnum )
191 || $conf->invoice_from_full( $self->cust_or_prospect->agentnum );
192 $self->SUPER::email( {
203 $self->conf->config('quotation_subject') #, $self->cust_main->agentnum)
206 #my $cust_main = $self->cust_main;
207 #my $name = $cust_main->name;
208 #my $name_short = $cust_main->name_short;
209 #my $invoice_number = $self->invnum;
210 #my $invoice_date = $self->_date_pretty;
215 =item cust_or_prosect
219 sub cust_or_prospect {
221 $self->custnum ? $self->cust_main : $self->prospect_main;
224 =item cust_or_prospect_label_link P
226 HTML links to either the customer or prospect.
228 Returns a list consisting of two elements. The first is a text label for the
229 link, and the second is the URL.
233 sub cust_or_prospect_label_link {
234 my( $self, $p ) = @_;
236 if ( my $custnum = $self->custnum ) {
237 my $display_custnum = $self->cust_main->display_custnum;
238 my $target = $FS::CurrentUser::CurrentUser->default_customer_view eq 'jumbo'
240 : ';show=quotations';
242 emt("View this customer (#[_1])",$display_custnum) =>
243 "${p}view/cust_main.cgi?custnum=$custnum$target"
245 } elsif ( my $prospectnum = $self->prospectnum ) {
247 emt("View this prospect (#[_1])",$prospectnum) =>
248 "${p}view/prospect_main.html?$prospectnum"
261 shift->cust_bill_pkg;
266 $self->quotationnum =~ /^(\d+)$/ or return ();
270 # show taxes in here also; the setup/recurring breakdown is different
271 # from what Template_Mixin expects
272 my @setup_tax = qsearch({
273 select => 'itemdesc, SUM(setup_amount) as setup_amount',
274 table => 'quotation_pkg_tax',
275 addl_from => ' JOIN quotation_pkg USING (quotationpkgnum) ',
276 extra_sql => ' WHERE quotationnum = '.$1,
277 order_by => ' GROUP BY itemdesc HAVING SUM(setup_amount) > 0' .
278 ' ORDER BY itemdesc',
280 # recurs need to be grouped by frequency, and to have a pkgpart
281 my @recur_tax = qsearch({
282 select => 'freq, itemdesc, SUM(recur_amount) as recur_amount, MAX(pkgpart) as pkgpart',
283 table => 'quotation_pkg_tax',
284 addl_from => ' JOIN quotation_pkg USING (quotationpkgnum)'.
285 ' JOIN part_pkg USING (pkgpart)',
286 extra_sql => ' WHERE quotationnum = '.$1,
287 order_by => ' GROUP BY freq, itemdesc HAVING SUM(recur_amount) > 0' .
288 ' ORDER BY freq, itemdesc',
291 my $total_setup = $self->total_setup;
292 foreach my $pkg_tax (@setup_tax) {
293 if ($pkg_tax->setup_amount > 0) {
294 $total_setup += $pkg_tax->setup_amount;
296 'total_item' => $pkg_tax->itemdesc . ' ' . $self->mt('(setup)'),
297 'total_amount' => $pkg_tax->setup_amount,
302 if ( $total_setup > 0 ) {
304 'total_item' => $self->mt( $self->total_recur > 0 ? 'Total Setup' : 'Total' ),
305 'total_amount' => sprintf('%.2f',$total_setup),
306 'break_after' => ( scalar(@recur_tax) ? 1 : 0 )
310 #could/should add up the different recurring frequencies on lines of their own
311 # but this will cover the 95% cases for now
312 my $total_recur = $self->total_recur;
313 # label these with the frequency
314 foreach my $pkg_tax (@recur_tax) {
315 if ($pkg_tax->recur_amount > 0) {
316 $total_recur += $pkg_tax->recur_amount;
317 # an arbitrary part_pkg, but with the right frequency
319 my $part_pkg = qsearchs('part_pkg', { pkgpart => $pkg_tax->pkgpart });
321 'total_item' => $pkg_tax->itemdesc . ' (' . $part_pkg->freq_pretty . ')',
322 'total_amount' => $pkg_tax->recur_amount,
327 if ( $total_recur > 0 ) {
329 'total_item' => $self->mt('Total Recurring'),
330 'total_amount' => sprintf('%.2f',$total_recur),
339 =item enable_previous
343 sub enable_previous { 0 }
345 =item convert_cust_main
347 If this quotation already belongs to a customer, then returns that customer, as
348 an FS::cust_main object.
350 Otherwise, creates a new customer (FS::cust_main object and record, and
351 associated) based on this quotation's prospect, then orders this quotation's
352 packages as real packages for the customer.
354 If there is an error, returns an error message, otherwise, returns the
355 newly-created FS::cust_main object.
359 sub convert_cust_main {
362 my $cust_main = $self->cust_main;
363 return $cust_main if $cust_main; #already converted, don't again
365 my $oldAutoCommit = $FS::UID::AutoCommit;
366 local $FS::UID::AutoCommit = 0;
369 $cust_main = $self->prospect_main->convert_cust_main;
370 unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
371 $dbh->rollback if $oldAutoCommit;
375 $self->prospectnum('');
376 $self->custnum( $cust_main->custnum );
377 my $error = $self->replace || $self->order;
379 $dbh->rollback if $oldAutoCommit;
383 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
389 =item order [ HASHREF ]
391 This method is for use with quotations which are already associated with a customer.
393 Orders this quotation's packages as real packages for the customer.
395 If there is an error, returns an error message, otherwise returns false.
397 If HASHREF is passed, it will be filled with a hash mapping the
398 C<quotationpkgnum> of each quoted package to the C<pkgnum> of the package
405 my $pkgnum_map = shift || {};
407 tie my %all_cust_pkg, 'Tie::RefHash';
408 foreach my $quotation_pkg ($self->quotation_pkg) {
409 my $cust_pkg = FS::cust_pkg->new;
410 $pkgnum_map->{ $quotation_pkg->quotationpkgnum } = $cust_pkg;
412 foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
413 $cust_pkg->set( $_, $quotation_pkg->get($_) );
416 # currently only one discount each
417 my ($pkg_discount) = $quotation_pkg->quotation_pkg_discount;
418 if ( $pkg_discount ) {
419 $cust_pkg->set('discountnum', $pkg_discount->discountnum);
422 $all_cust_pkg{$cust_pkg} = []; # no services
425 my $error = $self->cust_main->order_pkgs( \%all_cust_pkg );
427 foreach my $quotationpkgnum (keys %$pkgnum_map) {
428 # convert the objects to just pkgnums
429 my $cust_pkg = $pkgnum_map->{$quotationpkgnum};
430 $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum;
438 One-time charges, like FS::cust_main::charge()
442 #super false laziness w/cust_main::charge
445 my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
446 my ( $pkg, $comment, $additional );
447 my ( $setuptax, $taxclass ); #internal taxes
448 my ( $taxproduct, $override ); #vendor (CCH) taxes
450 my $cust_pkg_ref = '';
451 my ( $bill_now, $invoice_terms ) = ( 0, '' );
453 if ( ref( $_[0] ) ) {
454 $amount = $_[0]->{amount};
455 $setup_cost = $_[0]->{setup_cost};
456 $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
457 $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
458 $no_auto = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
459 $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
460 $comment = exists($_[0]->{comment}) ? $_[0]->{comment}
461 : '$'. sprintf("%.2f",$amount);
462 $setuptax = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
463 $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
464 $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
465 $additional = $_[0]->{additional} || [];
466 $taxproduct = $_[0]->{taxproductnum};
467 $override = { '' => $_[0]->{tax_override} };
468 $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
469 $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
470 $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
471 $locationnum = $_[0]->{locationnum};
477 $pkg = @_ ? shift : 'One-time charge';
478 $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
480 $taxclass = @_ ? shift : '';
484 local $SIG{HUP} = 'IGNORE';
485 local $SIG{INT} = 'IGNORE';
486 local $SIG{QUIT} = 'IGNORE';
487 local $SIG{TERM} = 'IGNORE';
488 local $SIG{TSTP} = 'IGNORE';
489 local $SIG{PIPE} = 'IGNORE';
491 my $oldAutoCommit = $FS::UID::AutoCommit;
492 local $FS::UID::AutoCommit = 0;
495 my $part_pkg = new FS::part_pkg ( {
497 'comment' => $comment,
501 'classnum' => ( $classnum ? $classnum : '' ),
502 'setuptax' => $setuptax,
503 'taxclass' => $taxclass,
504 'taxproductnum' => $taxproduct,
505 'setup_cost' => $setup_cost,
508 my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
509 ( 0 .. @$additional - 1 )
511 'additional_count' => scalar(@$additional),
512 'setup_fee' => $amount,
515 my $error = $part_pkg->insert( options => \%options,
516 tax_overrides => $override,
519 $dbh->rollback if $oldAutoCommit;
523 my $pkgpart = $part_pkg->pkgpart;
526 my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
528 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
529 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
530 $error = $type_pkgs->insert;
532 $dbh->rollback if $oldAutoCommit;
537 #except for DIFF, eveything above is idential to cust_main version
538 #but below is our own thing pretty much (adding a quotation package instead
539 # of ordering a customer package, no "bill now")
541 my $quotation_pkg = new FS::quotation_pkg ( {
542 'quotationnum' => $self->quotationnum,
543 'pkgpart' => $pkgpart,
544 'quantity' => $quantity,
545 #'start_date' => $start_date,
546 #'no_auto' => $no_auto,
547 'locationnum'=> $locationnum,
550 $error = $quotation_pkg->insert;
552 $dbh->rollback if $oldAutoCommit;
554 #} elsif ( $cust_pkg_ref ) {
555 # ${$cust_pkg_ref} = $cust_pkg;
558 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
565 Disables this quotation (sets disabled to Y, which hides the quotation on
566 prospects and customers).
568 If there is an error, returns an error message, otherwise returns false.
574 $self->disabled('Y');
580 Enables this quotation.
582 If there is an error, returns an error message, otherwise returns false.
594 Calculates current prices for all items on this quotation, including
595 discounts and taxes, and updates the quotation_pkg records accordingly.
601 my $conf = FS::Conf->new;
603 my %pkgnum_of; # quotationpkgnum => temporary pkgnum
605 my $me = "[quotation #".$self->quotationnum."]"; # for debug messages
607 my @return_bill = ([]);
610 ###### BEGIN TRANSACTION ######
613 my $temp_dbh = myconnect();
614 local $FS::UID::dbh = $temp_dbh;
615 local $FS::UID::AutoCommit = 0;
617 my $fake_self = FS::quotation->new({ $self->hash });
619 # if this is a prospect, make them into a customer for now
620 # XXX prospects currently can't have service locations
621 my $cust_or_prospect = $self->cust_or_prospect;
623 if ( $cust_or_prospect->isa('FS::prospect_main') ) {
624 $cust_main = $cust_or_prospect->convert_cust_main;
625 die "$cust_main (simulating customer signup)\n" unless ref $cust_main;
626 $fake_self->set('prospectnum', '');
627 $fake_self->set('custnum', $cust_main->custnum);
629 $cust_main = $cust_or_prospect;
633 $error = $fake_self->order(\%pkgnum_of);
634 die "$error (simulating package order)\n" if $error;
636 my @new_pkgs = map { FS::cust_pkg->by_key($_) } values(%pkgnum_of);
638 # simulate the first bill
640 'pkg_list' => \@new_pkgs,
641 'time' => time, # an option to adjust this?
642 'return_bill' => $return_bill[0],
643 'no_usage_reset' => 1,
645 $error = $cust_main->bill(%bill_opt);
646 die "$error (simulating initial billing)\n" if $error;
648 # pick dates for future bills
650 foreach (@new_pkgs) {
651 my $bill = $_->get('bill');
653 push @{ $next_bill_pkgs{$bill} ||= [] }, $_;
657 foreach my $next_bill (keys %next_bill_pkgs) {
658 $bill_opt{'time'} = $next_bill;
659 $bill_opt{'return_bill'} = $return_bill[$i] = [];
660 $bill_opt{'pkg_list'} = $next_bill_pkgs{$next_bill};
661 $error = $cust_main->bill(%bill_opt);
662 die "$error (simulating recurring billing cycle $i)\n" if $error;
669 ###### END TRANSACTION ######
670 my %quotationpkgnum_of = reverse %pkgnum_of;
673 warn "pkgnums:\n".Dumper(\%pkgnum_of);
674 warn Dumper(\@return_bill);
677 # careful: none of the pkgnums in here are correct outside the sandbox.
678 my %quotation_pkg; # quotationpkgnum => quotation_pkg
679 foreach my $qp ($self->quotation_pkg) {
680 $quotation_pkg{$qp->quotationpkgnum} = $qp;
681 $qp->set($_, 0) foreach qw(unitsetup unitrecur);
682 $qp->set('freq', '');
683 # flush old tax records
684 foreach ($qp->quotation_pkg_tax, $qp->quotation_pkg_discount) {
686 return "$error (flushing tax records for pkgpart ".$qp->part_pkg->pkgpart.")"
691 my %quotation_pkg_tax; # quotationpkgnum => taxnum => quotation_pkg_tax obj
693 for (my $i = 0; $i < scalar(@return_bill); $i++) {
694 my $this_bill = $return_bill[$i]->[0];
696 warn "$me billing cycle $i produced no invoice\n";
702 foreach my $cust_bill_pkg (@{ $this_bill->get('cust_bill_pkg') }) {
703 my $pkgnum = $cust_bill_pkg->pkgnum;
704 $cust_bill_pkg{ $cust_bill_pkg->billpkgnum } = $cust_bill_pkg;
706 # taxes/fees; come back to it
707 push @nonpkg_lines, $cust_bill_pkg;
710 my $quotationpkgnum = $quotationpkgnum_of{$pkgnum};
711 my $qp = $quotation_pkg{$quotationpkgnum};
713 # XXX supplemental packages could do this (they have separate pkgnums)
714 # handle that special case at some point
715 warn "$me simulated bill returned a package not on the quotation (pkgpart ".$cust_bill_pkg->pkgpart.")\n";
719 # then this is the first (setup) invoice
720 $qp->set('start_date', $cust_bill_pkg->sdate);
721 $qp->set('unitsetup', $qp->unitsetup + $cust_bill_pkg->unitsetup);
722 # pkgpart_override is a possibility
724 # recurring invoice (should be only one of these per package, though
725 # it may have multiple lineitems with the same pkgnum)
726 $qp->set('unitrecur', $qp->unitrecur + $cust_bill_pkg->unitrecur);
729 foreach my $cust_bill_pkg (@nonpkg_lines) {
730 if ($cust_bill_pkg->feepart) {
731 warn "$me simulated bill included a non-package fee (feepart ".
732 $cust_bill_pkg->feepart.")\n";
735 my $links = $cust_bill_pkg->get('cust_bill_pkg_tax_location') ||
736 $cust_bill_pkg->get('cust_bill_pkg_tax_rate_location') ||
738 # breadth-first unrolled recursion
739 while (my $tax_link = shift @$links) {
740 my $target = $cust_bill_pkg{ $tax_link->taxable_billpkgnum }
741 or die "$me unable to resolve tax link (taxnum ".$tax_link->taxnum.")\n";
742 if ($target->pkgnum) {
743 my $quotationpkgnum = $quotationpkgnum_of{$target->pkgnum};
744 # create this if there isn't one yet
746 $quotation_pkg_tax{$quotationpkgnum}{$tax_link->taxnum} ||=
747 FS::quotation_pkg_tax->new({
748 quotationpkgnum => $quotationpkgnum,
749 itemdesc => $cust_bill_pkg->itemdesc,
750 taxnum => $tax_link->taxnum,
751 taxtype => $tax_link->taxtype,
755 if ( $i == 0 ) { # first invoice
756 $qpt->set('setup_amount', $qpt->setup_amount + $tax_link->amount);
757 } else { # subsequent invoices
758 # this isn't perfectly accurate, but that's why it's an estimate
759 $qpt->set('recur_amount', $qpt->recur_amount + $tax_link->amount);
760 $qpt->set('setup_amount', sprintf('%.2f', $qpt->setup_amount - $tax_link->amount));
761 $qpt->set('setup_amount', 0) if $qpt->setup_amount < 0;
763 } elsif ($target->feepart) {
764 # do nothing; we already warned for the fee itself
766 # tax on tax: the tax target is another tax item
767 # since this is an estimate, I'm just going to assign it to the
768 # first of the underlying packages
769 my $sublinks = $target->cust_bill_pkg_tax_rate_location;
770 if ($sublinks and $sublinks->[0]) {
771 $tax_link->set('taxable_billpkgnum', $sublinks->[0]->taxable_billpkgnum);
772 push @$links, $tax_link; #try again
774 warn "$me unable to assign tax on tax; ignoring\n";
777 } # while my $tax_link
778 } # foreach my $cust_bill_pkg
781 foreach my $quotation_pkg (values %quotation_pkg) {
782 $error = $quotation_pkg->replace;
783 return "$error (recording estimate for ".$quotation_pkg->part_pkg->pkg.")"
786 foreach my $quotation_pkg_tax (map { values %$_ } values %quotation_pkg_tax) {
787 $error = $quotation_pkg_tax->insert;
788 return "$error (recording estimated tax for ".$quotation_pkg_tax->itemdesc.")"
801 =item search_sql_where HASHREF
803 Class method which returns an SQL WHERE fragment to search for parameters
804 specified in HASHREF. Valid parameters are
810 List reference of start date, end date, as UNIX timestamps.
820 List reference of charged limits (exclusive).
824 List reference of charged limits (exclusive).
828 flag, return open invoices only
832 flag, return net invoices only
840 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
844 sub search_sql_where {
845 my($class, $param) = @_;
847 # warn "$me search_sql_where called with params: \n".
848 # join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
854 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
855 push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
859 # if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
860 # push @search, "cust_main.refnum = $1";
864 if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
865 push @search, "quotation.prospectnum = $1";
869 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
870 push @search, "cust_bill.custnum = $1";
874 if ( $param->{_date} ) {
875 my($beginning, $ending) = @{$param->{_date}};
877 push @search, "quotation._date >= $beginning",
878 "quotation._date < $ending";
882 if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
883 push @search, "quotation.quotationnum >= $1";
885 if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
886 push @search, "quotation.quotationnum <= $1";
890 # if ( $param->{charged} ) {
891 # my @charged = ref($param->{charged})
892 # ? @{ $param->{charged} }
893 # : ($param->{charged});
895 # push @search, map { s/^charged/cust_bill.charged/; $_; }
899 my $owed_sql = FS::cust_bill->owed_sql;
902 push @search, "quotation._date < ". (time-86400*$param->{'days'})
905 #agent virtualization
906 my $curuser = $FS::CurrentUser::CurrentUser;
907 #false laziness w/search/quotation.html
908 push @search,' ( '. $curuser->agentnums_sql( table=>'prospect_main' ).
909 ' OR '. $curuser->agentnums_sql( table=>'cust_main' ).
912 join(' AND ', @search );
918 Return line item hashes for each package on this quotation. Differs from the
919 base L<FS::Template_Mixin> version in that it recalculates each quoted package
920 first, and doesn't implement the "condensed" option.
925 my ($self, %options) = @_;
927 # run it through the Template_Mixin engine
928 return $self->_items_cust_bill_pkg([ $self->quotation_pkg ], %options);
937 L<FS::Record>, schema.html from the base documentation.