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;
24 FS::quotation - Object methods for quotation records
30 $record = new FS::quotation \%hash;
31 $record = new FS::quotation { 'column' => 'value' };
33 $error = $record->insert;
35 $error = $new_record->replace($old_record);
37 $error = $record->delete;
39 $error = $record->check;
43 An FS::quotation object represents a quotation. FS::quotation inherits from
44 FS::Record. The following fields are currently supported:
74 projected date when the quotation will be closed
78 projected confidence (expressed as integer) that quotation will close
88 Creates a new quotation. To add the quotation to the database, see L<"insert">.
90 Note that this stores the hash reference, not a distinct copy of the hash it
91 points to. You can ask the object for a copy with the I<hash> method.
95 sub table { 'quotation'; }
96 sub notice_name { 'Quotation'; }
97 sub template_conf { 'quotation_'; }
98 sub has_sections { 1; }
102 Adds this record to the database. If there is an error, returns the error,
103 otherwise returns false.
107 Delete this record from the database.
109 =item replace OLD_RECORD
111 Replaces the OLD_RECORD with this one in the database. If there is an error,
112 returns the error, otherwise returns false.
116 Checks all fields to make sure this is a valid quotation. If there is
117 an error, returns the error, otherwise returns false. Called by the insert
126 $self->ut_numbern('quotationnum')
127 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum' )
128 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum' )
129 || $self->ut_numbern('_date')
130 || $self->ut_enum('disabled', [ '', 'Y' ])
131 || $self->ut_numbern('usernum')
132 || $self->ut_numbern('close_date')
133 || $self->ut_numbern('confidence')
135 return $error if $error;
137 $self->_date(time) unless $self->_date;
139 $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
141 return 'confidence must be an integer between 1 and 100'
142 if length($self->confidence) && (($self->confidence < 1) || ($self->confidence > 100));
144 return 'prospectnum or custnum must be specified'
145 if ! $self->prospectnum
159 sub cust_bill_pkg { #actually quotation_pkg objects
160 shift->quotation_pkg(@_);
169 sprintf('%.2f', $self->_total('setup') + $self->_total('setup_tax'));
172 =item total_recur [ FREQ ]
178 #=item total_recur [ FREQ ]
179 #my $freq = @_ ? shift : '';
180 sprintf('%.2f', $self->_total('recur') + $self->_total('recur_tax'));
184 my( $self, $method ) = @_;
187 $total += $_->$method() for $self->quotation_pkg;
188 sprintf('%.2f', $total);
194 my $opt = shift || {};
195 if ($opt and !ref($opt)) {
196 die ref($self). '->email called with positional parameters';
199 my $conf = $self->conf;
201 my $from = delete $opt->{from};
203 # this is where we set the From: address
204 $from ||= $conf->config('quotation_from', $self->cust_or_prospect->agentnum )
205 || $conf->invoice_from_full( $self->cust_or_prospect->agentnum );
206 $self->SUPER::email( {
217 $self->conf->config('quotation_subject') #, $self->cust_main->agentnum)
220 #my $cust_main = $self->cust_main;
221 #my $name = $cust_main->name;
222 #my $name_short = $cust_main->name_short;
223 #my $invoice_number = $self->invnum;
224 #my $invoice_date = $self->_date_pretty;
231 'Quotation-'. $self->quotationnum. '.pdf';
234 =item cust_or_prosect
238 sub cust_or_prospect {
240 $self->custnum ? $self->cust_main : $self->prospect_main;
243 =item cust_or_prospect_label_link
245 HTML links to either the customer or prospect.
247 Returns a list consisting of two elements. The first is a text label for the
248 link, and the second is the URL.
252 sub cust_or_prospect_label_link {
253 my( $self, $p ) = @_;
255 if ( my $custnum = $self->custnum ) {
256 my $display_custnum = $self->cust_main->display_custnum;
257 my $target = $FS::CurrentUser::CurrentUser->default_customer_view eq 'jumbo'
259 : ';show=quotations';
261 emt("View this customer (#[_1])",$display_custnum) =>
262 "${p}view/cust_main.cgi?custnum=$custnum$target"
264 } elsif ( my $prospectnum = $self->prospectnum ) {
266 emt("View this prospect (#[_1])",$prospectnum) =>
267 "${p}view/prospect_main.html?$prospectnum"
275 sub _items_sections {
278 my $escape = $opt{escape}; # the only one we care about
280 my %show; # package frequency => 1 if there's anything to display
281 my %subtotals = (); # package frequency => subtotal
282 my $disable_total = 0;
283 foreach my $pkg ($self->quotation_pkg) {
285 my $part_pkg = $pkg->part_pkg;
287 my $recur_freq = $part_pkg->freq;
288 $show{$recur_freq} = 1 if $pkg->unitrecur > 0 or $pkg->recur_show_zero;
289 $show{0} = 1 if $pkg->unitsetup > 0 or $pkg->setup_show_zero;
290 ($subtotals{0} ||= 0) += $pkg->setup + $pkg->setup_tax;
291 ($subtotals{$recur_freq} ||= 0) += $pkg->recur + $pkg->recur_tax;
293 #this is a shitty hack based on what's in part_pkg/ at the moment
294 # but its good enough for the 99% common case of preventing totals from
295 # displaying for prorate packages
297 if $part_pkg->plan =~ /^(prorate|torrus|agent$)/
298 || $part_pkg->option('recur_method') eq 'prorate'
299 || ( $part_pkg->option('sync_bill_date')
301 && $self->cust_main->billing_pkgs #num_billing_pkgs when we have it
305 my @pkg_freq_order = keys %{ FS::Misc->pkg_freqs };
308 my $no_recurring = 0;
309 foreach my $freq (keys %subtotals) {
311 #next if $subtotals{$freq} == 0;
312 next if !$show{$freq};
315 List::MoreUtils::first_index { $_ eq $freq } @pkg_freq_order;
317 if ( $freq eq '0' ) {
318 if ( scalar(keys(%subtotals)) == 1 ) {
319 # there are no recurring packages
321 $desc = $self->mt('Charges');
323 $desc = $self->mt('Setup Charges');
326 $desc = $self->mt('Recurring Charges') . ' - ' .
327 ucfirst($self->mt(FS::Misc->pkg_freqs->{$freq}))
331 'description' => &$escape($desc),
332 'sort_weight' => $weight,
334 'subtotal' => sprintf('%.2f',$subtotals{$freq}),
338 unless ( $disable_total || $no_recurring ) {
340 $total += $_ for values %subtotals;
342 'description' => 'First payment',
344 'category' => 'Total category', #required but what's it used for?
345 'subtotal' => sprintf('%.2f',$total)
349 return \@sections, [];
352 =item enable_previous
356 sub enable_previous { 0 }
358 =item convert_cust_main [ PARAMS ]
360 If this quotation already belongs to a customer, then returns that customer, as
361 an FS::cust_main object.
363 Otherwise, creates a new customer (FS::cust_main object and record, and
364 associated) based on this quotation's prospect, then orders this quotation's
365 packages as real packages for the customer.
367 If there is an error, returns an error message, otherwise, returns the
368 newly-created FS::cust_main object.
370 Accepts the same params as L</order>.
374 sub convert_cust_main {
376 my $params = shift || {};
378 my $cust_main = $self->cust_main;
379 return $cust_main if $cust_main; #already converted, don't again
381 my $oldAutoCommit = $FS::UID::AutoCommit;
382 local $FS::UID::AutoCommit = 0;
385 $cust_main = $self->prospect_main->convert_cust_main;
386 unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
387 $dbh->rollback if $oldAutoCommit;
391 $self->prospectnum('');
392 $self->custnum( $cust_main->custnum );
393 my $error = $self->replace || $self->order(undef,$params);
395 $dbh->rollback if $oldAutoCommit;
399 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
405 =item order [ HASHREF ] [ PARAMS ]
407 This method is for use with quotations which are already associated with a customer.
409 Orders this quotation's packages as real packages for the customer.
411 If there is an error, returns an error message, otherwise returns false.
413 If HASHREF is passed, it will be filled with a hash mapping the
414 C<quotationpkgnum> of each quoted package to the C<pkgnum> of the package
417 If PARAMS hashref is passed, the following params are accepted:
419 onhold - if true, suspends newly ordered packages
425 my $pkgnum_map = shift || {};
426 my $params = shift || {};
427 my $details_map = {};
429 tie my %all_cust_pkg, 'Tie::RefHash';
430 foreach my $quotation_pkg ($self->quotation_pkg) {
431 my $cust_pkg = FS::cust_pkg->new;
432 $pkgnum_map->{ $quotation_pkg->quotationpkgnum } = $cust_pkg;
434 # details will be copied below, after package is ordered
435 $details_map->{ $quotation_pkg->quotationpkgnum } = [
436 map { $_->copy_on_order ? $_->detail : () } $quotation_pkg->quotation_pkg_detail
439 foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
440 $cust_pkg->set( $_, $quotation_pkg->get($_) );
443 # can now have two discounts each (setup and recur)
444 foreach my $pkg_discount ($quotation_pkg->quotation_pkg_discount) {
445 my $field = $pkg_discount->setuprecur . '_discountnum';
446 $cust_pkg->set($field, $pkg_discount->discountnum);
449 $all_cust_pkg{$cust_pkg} = []; # no services
452 local $SIG{HUP} = 'IGNORE';
453 local $SIG{INT} = 'IGNORE';
454 local $SIG{QUIT} = 'IGNORE';
455 local $SIG{TERM} = 'IGNORE';
456 local $SIG{TSTP} = 'IGNORE';
457 local $SIG{PIPE} = 'IGNORE';
459 my $oldAutoCommit = $FS::UID::AutoCommit;
460 local $FS::UID::AutoCommit = 0;
463 my $error = $self->cust_main->order_pkgs( \%all_cust_pkg );
466 # copy details (copy_on_order filtering handled above)
467 foreach my $quotationpkgnum (keys %$details_map) {
468 next unless @{$details_map->{$quotationpkgnum}};
469 $error = $pkgnum_map->{$quotationpkgnum}->set_cust_pkg_detail(
471 @{$details_map->{$quotationpkgnum}}
477 if ($$params{'onhold'}) {
478 foreach my $quotationpkgnum (keys %$pkgnum_map) {
480 $error = $pkgnum_map->{$quotationpkgnum}->suspend();
485 $dbh->rollback if $oldAutoCommit;
489 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
491 foreach my $quotationpkgnum (keys %$pkgnum_map) {
492 # convert the objects to just pkgnums
493 my $cust_pkg = $pkgnum_map->{$quotationpkgnum};
494 $pkgnum_map->{$quotationpkgnum} = $cust_pkg->pkgnum;
503 One-time charges, like FS::cust_main::charge()
507 #super false laziness w/cust_main::charge
510 my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
511 my ( $pkg, $comment, $additional );
512 my ( $setuptax, $taxclass ); #internal taxes
513 my ( $taxproduct, $override ); #vendor (CCH) taxes
515 my $cust_pkg_ref = '';
516 my ( $bill_now, $invoice_terms ) = ( 0, '' );
518 my ( $discountnum, $discountnum_amount, $discountnum_percent ) = ( '','','' );
519 if ( ref( $_[0] ) ) {
520 $amount = $_[0]->{amount};
521 $setup_cost = $_[0]->{setup_cost};
522 $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
523 $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
524 $no_auto = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
525 $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
526 $comment = exists($_[0]->{comment}) ? $_[0]->{comment}
527 : '$'. sprintf("%.2f",$amount);
528 $setuptax = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
529 $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
530 $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
531 $additional = $_[0]->{additional} || [];
532 $taxproduct = $_[0]->{taxproductnum};
533 $override = { '' => $_[0]->{tax_override} };
534 $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
535 $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
536 $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
537 $locationnum = $_[0]->{locationnum};
538 $discountnum = $_->{setup_discountnum};
539 $discountnum_amount = $_->{setup_discountnum_amount};
540 $discountnum_percent = $_->{setup_discountnum_percent};
546 $pkg = @_ ? shift : 'One-time charge';
547 $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
549 $taxclass = @_ ? shift : '';
553 local $SIG{HUP} = 'IGNORE';
554 local $SIG{INT} = 'IGNORE';
555 local $SIG{QUIT} = 'IGNORE';
556 local $SIG{TERM} = 'IGNORE';
557 local $SIG{TSTP} = 'IGNORE';
558 local $SIG{PIPE} = 'IGNORE';
560 my $oldAutoCommit = $FS::UID::AutoCommit;
561 local $FS::UID::AutoCommit = 0;
564 my $part_pkg = new FS::part_pkg ( {
566 'comment' => $comment,
570 'classnum' => ( $classnum ? $classnum : '' ),
571 'setuptax' => $setuptax,
572 'taxclass' => $taxclass,
573 'taxproductnum' => $taxproduct,
574 'setup_cost' => $setup_cost,
577 my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
578 ( 0 .. @$additional - 1 )
580 'additional_count' => scalar(@$additional),
581 'setup_fee' => $amount,
584 my $error = $part_pkg->insert( options => \%options,
585 tax_overrides => $override,
588 $dbh->rollback if $oldAutoCommit;
592 my $pkgpart = $part_pkg->pkgpart;
595 my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
597 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
598 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
599 $error = $type_pkgs->insert;
601 $dbh->rollback if $oldAutoCommit;
606 #except for DIFF, eveything above is idential to cust_main version
607 #but below is our own thing pretty much (adding a quotation package instead
608 # of ordering a customer package, no "bill now")
610 my $quotation_pkg = new FS::quotation_pkg ( {
611 'quotationnum' => $self->quotationnum,
612 'pkgpart' => $pkgpart,
613 'quantity' => $quantity,
614 #'start_date' => $start_date,
615 #'no_auto' => $no_auto,
616 'locationnum' => $locationnum,
617 'setup_discountnum' => $discountnum,
618 'setup_discountnum_amount' => $discountnum_amount,
619 'setup_discountnum_percent' => $discountnum_percent,
622 $error = $quotation_pkg->insert;
624 $dbh->rollback if $oldAutoCommit;
626 #} elsif ( $cust_pkg_ref ) {
627 # ${$cust_pkg_ref} = $cust_pkg;
630 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
637 Disables this quotation (sets disabled to Y, which hides the quotation on
638 prospects and customers).
640 If there is an error, returns an error message, otherwise returns false.
646 $self->disabled('Y');
652 Enables this quotation.
654 If there is an error, returns an error message, otherwise returns false.
666 Calculates current prices for all items on this quotation, including
667 discounts and taxes, and updates the quotation_pkg records accordingly.
673 my $conf = FS::Conf->new;
675 my %pkgnum_of; # quotationpkgnum => temporary pkgnum
677 my $me = "[quotation #".$self->quotationnum."]"; # for debug messages
679 my @return_bill = ([]);
682 ###### BEGIN TRANSACTION ######
686 my $temp_dbh = myconnect();
687 local $FS::UID::dbh = $temp_dbh;
688 local $FS::UID::AutoCommit = 0;
690 my $fake_self = FS::quotation->new({ $self->hash });
692 # if this is a prospect, make them into a customer for now
693 # XXX prospects currently can't have service locations
694 my $cust_or_prospect = $self->cust_or_prospect;
696 if ( $cust_or_prospect->isa('FS::prospect_main') ) {
697 $cust_main = $cust_or_prospect->convert_cust_main;
698 die "$cust_main (simulating customer signup)\n" unless ref $cust_main;
699 $fake_self->set('prospectnum', '');
700 $fake_self->set('custnum', $cust_main->custnum);
702 $cust_main = $cust_or_prospect;
706 local($FS::cust_pkg::disable_start_on_hold) = 1;
707 $error = $fake_self->order(\%pkgnum_of);
708 die "$error (simulating package order)\n" if $error;
710 my @new_pkgs = map { FS::cust_pkg->by_key($_) } values(%pkgnum_of);
712 # simulate the first bill
715 'pkg_list' => \@new_pkgs,
716 'time' => time, # an option to adjust this?
717 'return_bill' => $return_bill[0],
718 'no_usage_reset' => 1,
720 $error = $cust_main->bill(%bill_opt);
721 die "$error (simulating initial billing)\n" if $error;
723 # pick dates for future bills
725 foreach (@new_pkgs) {
726 my $bill = $_->get('bill');
728 push @{ $next_bill_pkgs{$bill} ||= [] }, $_;
732 foreach my $next_bill (keys %next_bill_pkgs) {
733 $bill_opt{'time'} = $next_bill;
734 $bill_opt{'return_bill'} = $return_bill[$i] = [];
735 $bill_opt{'pkg_list'} = $next_bill_pkgs{$next_bill};
736 $error = $cust_main->bill(%bill_opt);
737 die "$error (simulating recurring billing cycle $i)\n" if $error;
744 ###### END TRANSACTION ######
745 my %quotationpkgnum_of = reverse %pkgnum_of;
748 warn "pkgnums:\n".Dumper(\%pkgnum_of);
749 warn Dumper(\@return_bill);
752 # Careful: none of the foreign keys in here are correct outside the sandbox.
753 # We have a translation table for pkgnums; all others are total lies.
755 my %quotation_pkg; # quotationpkgnum => quotation_pkg
756 foreach my $qp ($self->quotation_pkg) {
757 $quotation_pkg{$qp->quotationpkgnum} = $qp;
758 $qp->set($_, 0) foreach qw(unitsetup unitrecur);
759 $qp->set('freq', '');
760 # flush old tax records
761 foreach ($qp->quotation_pkg_tax) {
763 return "$error (flushing tax records for pkgpart ".$qp->part_pkg->pkgpart.")"
768 my %quotation_pkg_tax; # quotationpkgnum => tax name => quotation_pkg_tax obj
769 my %quotation_pkg_discount; # quotationpkgnum => setuprecur => quotation_pkg_discount obj
771 for (my $i = 0; $i < scalar(@return_bill); $i++) {
772 my $this_bill = $return_bill[$i]->[0];
774 warn "$me billing cycle $i produced no invoice\n";
780 foreach my $cust_bill_pkg (@{ $this_bill->get('cust_bill_pkg') }) {
781 my $pkgnum = $cust_bill_pkg->pkgnum;
782 $cust_bill_pkg{ $cust_bill_pkg->billpkgnum } = $cust_bill_pkg;
784 # taxes/fees; come back to it
785 push @nonpkg_lines, $cust_bill_pkg;
788 my $quotationpkgnum = $quotationpkgnum_of{$pkgnum};
789 my $qp = $quotation_pkg{$quotationpkgnum};
791 # XXX supplemental packages could do this (they have separate pkgnums)
792 # handle that special case at some point
793 warn "$me simulated bill returned a package not on the quotation (pkgpart ".$cust_bill_pkg->pkgpart.")\n";
797 # then this is the first (setup) invoice
798 $qp->set('start_date', $cust_bill_pkg->sdate);
799 $qp->set('unitsetup', $qp->unitsetup + $cust_bill_pkg->unitsetup);
800 # pkgpart_override is a possibility
802 # recurring invoice (should be only one of these per package, though
803 # it may have multiple lineitems with the same pkgnum)
804 $qp->set('unitrecur', $qp->unitrecur + $cust_bill_pkg->unitrecur);
808 if ( $cust_bill_pkg->get('discounts') ) {
809 # discount records are generated as (setup, recur).
810 # well, not always, sometimes it's just (recur), but fixing this
811 # is horribly invasive.
812 my $discount = $cust_bill_pkg->get('discounts')->[0];
815 # find the quotation_pkg_discount record for this billing pass...
816 my $setuprecur = $i ? 'recur' : 'setup';
817 my $qpd = $quotation_pkg_discount{$quotationpkgnum}{$setuprecur}
818 ||= qsearchs('quotation_pkg_discount', {
819 'quotationpkgnum' => $quotationpkgnum,
820 'setuprecur' => $setuprecur,
823 if (!$qpd) { #can't happen
824 warn "$me simulated bill returned a $setuprecur discount but no discount is in effect.\n";
827 $qpd->set('amount', $discount->amount);
830 } # end of discount stuff
835 foreach my $cust_bill_pkg (@nonpkg_lines) {
837 my $itemdesc = $cust_bill_pkg->itemdesc;
839 if ($cust_bill_pkg->feepart) {
840 warn "$me simulated bill included a non-package fee (feepart ".
841 $cust_bill_pkg->feepart.")\n";
844 my $links = $cust_bill_pkg->get('cust_bill_pkg_tax_location') ||
845 $cust_bill_pkg->get('cust_bill_pkg_tax_rate_location') ||
847 # breadth-first unrolled recursion:
848 # take each tax link and any tax-on-tax descendants, and merge them
849 # into a single quotation_pkg_tax record for each pkgnum/taxname
851 while (my $tax_link = shift @$links) {
852 my $target = $cust_bill_pkg{ $tax_link->taxable_billpkgnum }
853 or die "$me unable to resolve tax link\n";
854 if ($target->pkgnum) {
855 my $quotationpkgnum = $quotationpkgnum_of{$target->pkgnum};
856 # create this if there isn't one yet
857 my $qpt = $quotation_pkg_tax{$quotationpkgnum}{$itemdesc} ||=
858 FS::quotation_pkg_tax->new({
859 quotationpkgnum => $quotationpkgnum,
860 itemdesc => $itemdesc,
864 if ( $i == 0 ) { # first invoice
865 $qpt->set('setup_amount', $qpt->setup_amount + $tax_link->amount);
866 } else { # subsequent invoices
867 # this isn't perfectly accurate, but that's why it's an estimate
868 $qpt->set('recur_amount', $qpt->recur_amount + $tax_link->amount);
869 $qpt->set('setup_amount', sprintf('%.2f', $qpt->setup_amount - $tax_link->amount));
870 $qpt->set('setup_amount', 0) if $qpt->setup_amount < 0;
872 } elsif ($target->feepart) {
873 # do nothing; we already warned for the fee itself
875 # tax on tax: the tax target is another tax item.
876 # since this is an estimate, I'm just going to assign it to the
877 # first of the underlying packages. (RT#5243 is why we can't have
879 my $sublinks = $target->cust_bill_pkg_tax_rate_location;
880 if ($sublinks and $sublinks->[0]) {
881 $tax_link->set('taxable_billpkgnum', $sublinks->[0]->taxable_billpkgnum);
882 push @$links, $tax_link; #try again
884 warn "$me unable to assign tax on tax; ignoring\n";
887 } # while my $tax_link
889 } # foreach my $cust_bill_pkg
891 foreach my $quotation_pkg (values %quotation_pkg) {
892 $error = $quotation_pkg->replace;
893 return "$error (recording estimate for ".$quotation_pkg->part_pkg->pkg.")"
896 foreach (values %quotation_pkg_discount) {
897 # { setup => one, recur => another }
898 foreach my $quotation_pkg_discount (values %$_) {
899 $error = $quotation_pkg_discount->replace;
900 return "$error (recording estimated discount)"
904 foreach my $quotation_pkg_tax (map { values %$_ } values %quotation_pkg_tax) {
905 $error = $quotation_pkg_tax->insert;
906 return "$error (recording estimated tax for ".$quotation_pkg_tax->itemdesc.")"
919 =item search_sql_where HASHREF
921 Class method which returns an SQL WHERE fragment to search for parameters
922 specified in HASHREF. Valid parameters are
928 List reference of start date, end date, as UNIX timestamps.
938 List reference of charged limits (exclusive).
942 List reference of charged limits (exclusive).
946 flag, return open invoices only
950 flag, return net invoices only
958 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
962 sub search_sql_where {
963 my($class, $param) = @_;
965 # warn "$me search_sql_where called with params: \n".
966 # join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n";
972 if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
973 push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
977 # if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
978 # push @search, "cust_main.refnum = $1";
982 if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
983 push @search, "quotation.prospectnum = $1";
987 if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
988 push @search, "cust_bill.custnum = $1";
992 if ( $param->{_date} ) {
993 my($beginning, $ending) = @{$param->{_date}};
995 push @search, "quotation._date >= $beginning",
996 "quotation._date < $ending";
1000 if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
1001 push @search, "quotation.quotationnum >= $1";
1003 if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
1004 push @search, "quotation.quotationnum <= $1";
1008 # if ( $param->{charged} ) {
1009 # my @charged = ref($param->{charged})
1010 # ? @{ $param->{charged} }
1011 # : ($param->{charged});
1013 # push @search, map { s/^charged/cust_bill.charged/; $_; }
1017 my $owed_sql = FS::cust_bill->owed_sql;
1020 push @search, "quotation._date < ". (time-86400*$param->{'days'})
1021 if $param->{'days'};
1023 #agent virtualization
1024 my $curuser = $FS::CurrentUser::CurrentUser;
1025 #false laziness w/search/quotation.html
1026 push @search,' ( '. $curuser->agentnums_sql( table=>'prospect_main' ).
1027 ' OR '. $curuser->agentnums_sql( table=>'cust_main' ).
1030 join(' AND ', @search );
1036 Return line item hashes for each package on this quotation.
1041 my ($self, %options) = @_;
1042 my $escape = $options{'escape_function'};
1043 my $locale = $self->cust_or_prospect->locale;
1045 my $preref = $options{'preref_callback'};
1047 my $section = $options{'section'};
1048 my $freq = $section->{'category'};
1049 my @pkgs = $self->quotation_pkg;
1051 die "_items_pkg called without section->{'category'}"
1052 unless defined $freq;
1054 my %tax_item; # taxname => hashref, will be aggregated AT DISPLAY TIME
1055 # like we should have done in the first place
1057 foreach my $quotation_pkg (@pkgs) {
1058 my $part_pkg = $quotation_pkg->part_pkg;
1059 my @details = $quotation_pkg->details;
1062 'pkgnum' => $quotation_pkg->quotationpkgnum,
1063 'description' => $quotation_pkg->desc($locale),
1064 'ext_description' => \@details,
1065 'quantity' => $quotation_pkg->quantity,
1069 $setuprecur = 'setup';
1070 if ($part_pkg->freq ne '0') {
1071 # indicate that it's a setup fee on a recur package (cust_bill does
1073 $this_item->{'description'} .= ' Setup';
1076 # recur for this frequency
1077 next if $freq ne $part_pkg->freq;
1078 $setuprecur = 'recur';
1081 $this_item->{'unit_amount'} = sprintf('%.2f',
1082 $quotation_pkg->get('unit'.$setuprecur));
1083 $this_item->{'amount'} = sprintf('%.2f', $this_item->{'unit_amount'}
1084 * $quotation_pkg->quantity);
1085 next if $this_item->{'amount'} == 0 and !(
1086 $setuprecur eq 'setup'
1087 ? $quotation_pkg->setup_show_zero
1088 : $quotation_pkg->recur_show_zero
1092 $this_item->{'preref_html'} = &$preref($quotation_pkg);
1095 push @items, $this_item;
1096 my $discount = $quotation_pkg->_item_discount(setuprecur => $setuprecur);
1098 $_ = &{$escape}($_) foreach @{ $discount->{ext_description} };
1099 push @items, $discount;
1102 # each quotation_pkg_tax has two amounts: the amount charged on the
1103 # setup invoice, and the amount on the recurring invoice.
1104 foreach my $qpt ($quotation_pkg->quotation_pkg_tax) {
1105 my $this_tax = $tax_item{$qpt->itemdesc} ||= {
1107 'description' => $qpt->itemdesc,
1108 'ext_description' => [],
1111 $this_tax->{'amount'} += $qpt->get($setuprecur.'_amount');
1113 } # foreach $quotation_pkg
1115 foreach my $taxname ( sort { $a cmp $b } keys (%tax_item) ) {
1116 my $this_tax = $tax_item{$taxname};
1117 $this_tax->{'amount'} = sprintf('%.2f', $this_tax->{'amount'});
1118 next if $this_tax->{'amount'} == 0;
1119 push @items, $this_tax;
1135 L<FS::Record>, schema.html from the base documentation.