set default itemdesc="Tax" on quotation taxes, #32489
[freeside.git] / FS / FS / quotation.pm
1 package FS::quotation;
2 use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::otaker_Mixin FS::Record
3            );
4
5 use strict;
6 use Tie::RefHash;
7 use FS::CurrentUser;
8 use FS::UID qw( dbh );
9 use FS::Maketext qw( emt );
10 use FS::Record qw( qsearch qsearchs );
11 use FS::Conf;
12 use FS::cust_main;
13 use FS::cust_pkg;
14 use FS::quotation_pkg;
15 use FS::quotation_pkg_tax;
16 use FS::type_pkgs;
17
18 =head1 NAME
19
20 FS::quotation - Object methods for quotation records
21
22 =head1 SYNOPSIS
23
24   use FS::quotation;
25
26   $record = new FS::quotation \%hash;
27   $record = new FS::quotation { 'column' => 'value' };
28
29   $error = $record->insert;
30
31   $error = $new_record->replace($old_record);
32
33   $error = $record->delete;
34
35   $error = $record->check;
36
37 =head1 DESCRIPTION
38
39 An FS::quotation object represents a quotation.  FS::quotation inherits from
40 FS::Record.  The following fields are currently supported:
41
42 =over 4
43
44 =item quotationnum
45
46 primary key
47
48 =item prospectnum
49
50 prospectnum
51
52 =item custnum
53
54 custnum
55
56 =item _date
57
58 _date
59
60 =item disabled
61
62 disabled
63
64 =item usernum
65
66 usernum
67
68
69 =back
70
71 =head1 METHODS
72
73 =over 4
74
75 =item new HASHREF
76
77 Creates a new quotation.  To add the quotation to the database, see L<"insert">.
78
79 Note that this stores the hash reference, not a distinct copy of the hash it
80 points to.  You can ask the object for a copy with the I<hash> method.
81
82 =cut
83
84 sub table { 'quotation'; }
85 sub notice_name { 'Quotation'; }
86 sub template_conf { 'quotation_'; }
87
88 =item insert
89
90 Adds this record to the database.  If there is an error, returns the error,
91 otherwise returns false.
92
93 =item delete
94
95 Delete this record from the database.
96
97 =item replace OLD_RECORD
98
99 Replaces the OLD_RECORD with this one in the database.  If there is an error,
100 returns the error, otherwise returns false.
101
102 =item check
103
104 Checks all fields to make sure this is a valid quotation.  If there is
105 an error, returns the error, otherwise returns false.  Called by the insert
106 and replace methods.
107
108 =cut
109
110 sub check {
111   my $self = shift;
112
113   my $error = 
114     $self->ut_numbern('quotationnum')
115     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum' )
116     || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum' )
117     || $self->ut_numbern('_date')
118     || $self->ut_enum('disabled', [ '', 'Y' ])
119     || $self->ut_numbern('usernum')
120   ;
121   return $error if $error;
122
123   $self->_date(time) unless $self->_date;
124
125   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
126
127   return 'prospectnum or custnum must be specified'
128     if ! $self->prospectnum
129     && ! $self->custnum;
130
131   $self->SUPER::check;
132 }
133
134 =item prospect_main
135
136 =item cust_main
137
138 =item cust_bill_pkg
139
140 =cut
141
142 sub cust_bill_pkg { #actually quotation_pkg objects
143   shift->quotation_pkg(@_);
144 }
145
146 =item total_setup
147
148 =cut
149
150 sub total_setup {
151   my $self = shift;
152   $self->_total('setup');
153 }
154
155 =item total_recur [ FREQ ]
156
157 =cut
158
159 sub total_recur {
160   my $self = shift;
161 #=item total_recur [ FREQ ]
162   #my $freq = @_ ? shift : '';
163   $self->_total('recur');
164 }
165
166 sub _total {
167   my( $self, $method ) = @_;
168
169   my $total = 0;
170   $total += $_->$method() for $self->cust_bill_pkg;
171   sprintf('%.2f', $total);
172
173 }
174
175 sub email {
176   my $self = shift;
177   my $opt = shift || {};
178   if ($opt and !ref($opt)) {
179     die ref($self). '->email called with positional parameters';
180   }
181
182   my $conf = $self->conf;
183
184   my $from = delete $opt->{from};
185
186   # this is where we set the From: address
187   $from ||= $conf->config('quotation_from', $self->cust_or_prospect->agentnum )
188         ||  $conf->invoice_from_full( $self->cust_or_prospect->agentnum );
189   $self->SUPER::email( {
190     'from' => $from,
191     %$opt,
192   });
193
194 }
195
196 sub email_subject {
197   my $self = shift;
198
199   my $subject =
200     $self->conf->config('quotation_subject') #, $self->cust_main->agentnum)
201       || 'Quotation';
202
203   #my $cust_main = $self->cust_main;
204   #my $name = $cust_main->name;
205   #my $name_short = $cust_main->name_short;
206   #my $invoice_number = $self->invnum;
207   #my $invoice_date = $self->_date_pretty;
208
209   eval qq("$subject");
210 }
211
212 =item cust_or_prosect
213
214 =cut
215
216 sub cust_or_prospect {
217   my $self = shift;
218   $self->custnum ? $self->cust_main : $self->prospect_main;
219 }
220
221 =item cust_or_prospect_label_link P
222
223 HTML links to either the customer or prospect.
224
225 Returns a list consisting of two elements.  The first is a text label for the
226 link, and the second is the URL.
227
228 =cut
229
230 sub cust_or_prospect_label_link {
231   my( $self, $p ) = @_;
232
233   if ( my $custnum = $self->custnum ) {
234     my $display_custnum = $self->cust_main->display_custnum;
235     my $target = $FS::CurrentUser::CurrentUser->default_customer_view eq 'jumbo'
236                    ? '#quotations'
237                    : ';show=quotations';
238     (
239       emt("View this customer (#[_1])",$display_custnum) =>
240         "${p}view/cust_main.cgi?custnum=$custnum$target"
241     );
242   } elsif ( my $prospectnum = $self->prospectnum ) {
243     (
244       emt("View this prospect (#[_1])",$prospectnum) =>
245         "${p}view/prospect_main.html?$prospectnum"
246     );
247   } else { #die?
248     ( '', '' );
249   }
250
251 }
252
253 sub _items_tax {
254   ();
255 }
256
257 sub _items_nontax {
258   shift->cust_bill_pkg;
259 }
260
261 sub _items_total {
262   my $self = shift;
263   $self->quotationnum =~ /^(\d+)$/ or return ();
264
265   my @items;
266
267   # show taxes in here also; the setup/recurring breakdown is different
268   # from what Template_Mixin expects
269   my @setup_tax = qsearch({
270       select      => 'itemdesc, SUM(setup_amount) as setup_amount',
271       table       => 'quotation_pkg_tax',
272       addl_from   => ' JOIN quotation_pkg USING (quotationpkgnum) ',
273       extra_sql   => ' WHERE quotationnum = '.$1,
274       order_by    => ' GROUP BY itemdesc HAVING SUM(setup_amount) > 0' .
275                      ' ORDER BY itemdesc',
276   });
277   # recurs need to be grouped by frequency, and to have a pkgpart
278   my @recur_tax = qsearch({
279       select      => 'freq, itemdesc, SUM(recur_amount) as recur_amount, MAX(pkgpart) as pkgpart',
280       table       => 'quotation_pkg_tax',
281       addl_from   => ' JOIN quotation_pkg USING (quotationpkgnum)'.
282                      ' JOIN part_pkg USING (pkgpart)',
283       extra_sql   => ' WHERE quotationnum = '.$1,
284       order_by    => ' GROUP BY freq, itemdesc HAVING SUM(recur_amount) > 0' .
285                      ' ORDER BY freq, itemdesc',
286   });
287
288   my $total_setup = $self->total_setup;
289   foreach my $pkg_tax (@setup_tax) {
290     if ($pkg_tax->setup_amount > 0) {
291       $total_setup += $pkg_tax->setup_amount;
292       push @items, {
293         'total_item'    => $pkg_tax->itemdesc . ' ' . $self->mt('(setup)'),
294         'total_amount'  => $pkg_tax->setup_amount,
295       };
296     }
297   }
298
299   if ( $total_setup > 0 ) {
300     push @items, {
301       'total_item'   => $self->mt( $self->total_recur > 0 ? 'Total Setup' : 'Total' ),
302       'total_amount' => sprintf('%.2f',$total_setup),
303       'break_after'  => ( scalar(@recur_tax) ? 1 : 0 )
304     };
305   }
306
307   #could/should add up the different recurring frequencies on lines of their own
308   # but this will cover the 95% cases for now
309   my $total_recur = $self->total_recur;
310   # label these with the frequency
311   foreach my $pkg_tax (@recur_tax) {
312     if ($pkg_tax->recur_amount > 0) {
313       $total_recur += $pkg_tax->recur_amount;
314       # an arbitrary part_pkg, but with the right frequency
315       # XXX localization
316       my $part_pkg = qsearchs('part_pkg', { pkgpart => $pkg_tax->pkgpart });
317       push @items, {
318         'total_item'    => $pkg_tax->itemdesc . ' (' .  $part_pkg->freq_pretty . ')',
319         'total_amount'  => $pkg_tax->recur_amount,
320       };
321     }
322   }
323
324   if ( $total_recur > 0 ) {
325     push @items, {
326       'total_item'   => $self->mt('Total Recurring'),
327       'total_amount' => sprintf('%.2f',$total_recur),
328       'break_after'  => 1,
329     };
330   }
331
332   return @items;
333
334 }
335
336 =item enable_previous
337
338 =cut
339
340 sub enable_previous { 0 }
341
342 =item convert_cust_main
343
344 If this quotation already belongs to a customer, then returns that customer, as
345 an FS::cust_main object.
346
347 Otherwise, creates a new customer (FS::cust_main object and record, and
348 associated) based on this quotation's prospect, then orders this quotation's
349 packages as real packages for the customer.
350
351 If there is an error, returns an error message, otherwise, returns the
352 newly-created FS::cust_main object.
353
354 =cut
355
356 sub convert_cust_main {
357   my $self = shift;
358
359   my $cust_main = $self->cust_main;
360   return $cust_main if $cust_main; #already converted, don't again
361
362   my $oldAutoCommit = $FS::UID::AutoCommit;
363   local $FS::UID::AutoCommit = 0;
364   my $dbh = dbh;
365
366   $cust_main = $self->prospect_main->convert_cust_main;
367   unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
368     $dbh->rollback if $oldAutoCommit;
369     return $cust_main;
370   }
371
372   $self->prospectnum('');
373   $self->custnum( $cust_main->custnum );
374   my $error = $self->replace || $self->order;
375   if ( $error ) {
376     $dbh->rollback if $oldAutoCommit;
377     return $error;
378   }
379
380   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
381
382   $cust_main;
383
384 }
385
386 =item order
387
388 This method is for use with quotations which are already associated with a customer.
389
390 Orders this quotation's packages as real packages for the customer.
391
392 If there is an error, returns an error message, otherwise returns false.
393
394 =cut
395
396 sub order {
397   my $self = shift;
398
399   tie my %all_cust_pkg, 'Tie::RefHash';
400   foreach my $quotation_pkg ($self->quotation_pkg) {
401     my $cust_pkg = FS::cust_pkg->new;
402     foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
403       $cust_pkg->set( $_, $quotation_pkg->get($_) );
404     }
405
406     # currently only one discount each
407     my ($pkg_discount) = $quotation_pkg->quotation_pkg_discount;
408     if ( $pkg_discount ) {
409       $cust_pkg->set('discountnum', $pkg_discount->discountnum);
410     }
411
412     $all_cust_pkg{$cust_pkg} = []; # no services
413   }
414
415   $self->cust_main->order_pkgs( \%all_cust_pkg );
416
417 }
418
419 =item charge
420
421 One-time charges, like FS::cust_main::charge()
422
423 =cut
424
425 #super false laziness w/cust_main::charge
426 sub charge {
427   my $self = shift;
428   my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
429   my ( $pkg, $comment, $additional );
430   my ( $setuptax, $taxclass );   #internal taxes
431   my ( $taxproduct, $override ); #vendor (CCH) taxes
432   my $no_auto = '';
433   my $cust_pkg_ref = '';
434   my ( $bill_now, $invoice_terms ) = ( 0, '' );
435   my $locationnum;
436   if ( ref( $_[0] ) ) {
437     $amount     = $_[0]->{amount};
438     $setup_cost = $_[0]->{setup_cost};
439     $quantity   = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
440     $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
441     $no_auto    = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
442     $pkg        = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
443     $comment    = exists($_[0]->{comment}) ? $_[0]->{comment}
444                                            : '$'. sprintf("%.2f",$amount);
445     $setuptax   = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
446     $taxclass   = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
447     $classnum   = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
448     $additional = $_[0]->{additional} || [];
449     $taxproduct = $_[0]->{taxproductnum};
450     $override   = { '' => $_[0]->{tax_override} };
451     $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
452     $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
453     $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
454     $locationnum = $_[0]->{locationnum};
455   } else {
456     $amount     = shift;
457     $setup_cost = '';
458     $quantity   = 1;
459     $start_date = '';
460     $pkg        = @_ ? shift : 'One-time charge';
461     $comment    = @_ ? shift : '$'. sprintf("%.2f",$amount);
462     $setuptax   = '';
463     $taxclass   = @_ ? shift : '';
464     $additional = [];
465   }
466
467   local $SIG{HUP} = 'IGNORE';
468   local $SIG{INT} = 'IGNORE';
469   local $SIG{QUIT} = 'IGNORE';
470   local $SIG{TERM} = 'IGNORE';
471   local $SIG{TSTP} = 'IGNORE';
472   local $SIG{PIPE} = 'IGNORE';
473
474   my $oldAutoCommit = $FS::UID::AutoCommit;
475   local $FS::UID::AutoCommit = 0;
476   my $dbh = dbh;
477
478   my $part_pkg = new FS::part_pkg ( {
479     'pkg'           => $pkg,
480     'comment'       => $comment,
481     'plan'          => 'flat',
482     'freq'          => 0,
483     'disabled'      => 'Y',
484     'classnum'      => ( $classnum ? $classnum : '' ),
485     'setuptax'      => $setuptax,
486     'taxclass'      => $taxclass,
487     'taxproductnum' => $taxproduct,
488     'setup_cost'    => $setup_cost,
489   } );
490
491   my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
492                         ( 0 .. @$additional - 1 )
493                   ),
494                   'additional_count' => scalar(@$additional),
495                   'setup_fee' => $amount,
496                 );
497
498   my $error = $part_pkg->insert( options       => \%options,
499                                  tax_overrides => $override,
500                                );
501   if ( $error ) {
502     $dbh->rollback if $oldAutoCommit;
503     return $error;
504   }
505
506   my $pkgpart = $part_pkg->pkgpart;
507
508   #DIFF
509   my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
510
511   unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
512     my $type_pkgs = new FS::type_pkgs \%type_pkgs;
513     $error = $type_pkgs->insert;
514     if ( $error ) {
515       $dbh->rollback if $oldAutoCommit;
516       return $error;
517     }
518   }
519
520   #except for DIFF, eveything above is idential to cust_main version
521   #but below is our own thing pretty much (adding a quotation package instead
522   # of ordering a customer package, no "bill now")
523
524   my $quotation_pkg = new FS::quotation_pkg ( {
525     'quotationnum'  => $self->quotationnum,
526     'pkgpart'       => $pkgpart,
527     'quantity'      => $quantity,
528     #'start_date' => $start_date,
529     #'no_auto'    => $no_auto,
530     'locationnum'=> $locationnum,
531   } );
532
533   $error = $quotation_pkg->insert;
534   if ( $error ) {
535     $dbh->rollback if $oldAutoCommit;
536     return $error;
537   #} elsif ( $cust_pkg_ref ) {
538   #  ${$cust_pkg_ref} = $cust_pkg;
539   }
540
541   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
542   return '';
543
544 }
545
546 =item disable
547
548 Disables this quotation (sets disabled to Y, which hides the quotation on
549 prospects and customers).
550
551 If there is an error, returns an error message, otherwise returns false.
552
553 =cut
554
555 sub disable {
556   my $self = shift;
557   $self->disabled('Y');
558   $self->replace();
559 }
560
561 =item enable
562
563 Enables this quotation.
564
565 If there is an error, returns an error message, otherwise returns false.
566
567 =cut
568
569 sub enable {
570   my $self = shift;
571   $self->disabled('');
572   $self->replace();
573 }
574
575 =item estimate
576
577 Calculates current prices for all items on this quotation, including 
578 discounts and taxes, and updates the quotation_pkg records accordingly.
579
580 =cut
581
582 sub estimate {
583   my $self = shift;
584   my $conf = FS::Conf->new;
585
586   my $dbh = dbh;
587   my $oldAutoCommit = $FS::UID::AutoCommit;
588   local $FS::UID::AutoCommit = 0;
589
590   # bring individual items up to date (set setup/recur and discounts)
591   my @quotation_pkg = $self->quotation_pkg;
592   foreach my $pkg (@quotation_pkg) {
593     my $error = $pkg->estimate;
594     if ($error) {
595       $dbh->rollback if $oldAutoCommit;
596       die "error calculating estimate for pkgpart " . $pkg->pkgpart.": $error\n";
597     }
598
599     # delete old tax records
600     foreach my $quotation_pkg_tax ($pkg->quotation_pkg_tax) {
601       $error = $quotation_pkg_tax->delete;
602       if ( $error ) {
603         $dbh->rollback if $oldAutoCommit;
604         die "error flushing tax records for pkgpart ". $pkg->pkgpart.": $error\n";
605       }
606     }
607   }
608
609   # annoyingly duplicates handle_taxes--fix this in 4.x 
610   if ( $conf->exists('enable_taxproducts') ) {
611     warn "can't calculate external taxes for quotations yet\n";
612     # then we're done
613     return;
614   }
615
616   my %taxnum_exemptions; # for monthly exemptions; as yet unused
617
618   foreach my $pkg (@quotation_pkg) {
619     my $location = $pkg->cust_location;
620
621     my $part_item = $pkg->part_pkg; # we don't have fees on these yet
622     my @loc_keys = qw( district city county state country);
623     my %taxhash = map { $_ => $location->$_ } @loc_keys;
624     $taxhash{'taxclass'} = $part_item->taxclass;
625     my @taxes;
626     my %taxhash_elim = %taxhash;
627     my @elim = qw( district city county state );
628     do {
629       @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
630       if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
631         #then try a match without taxclass
632         my %no_taxclass = %taxhash_elim;
633         $no_taxclass{ 'taxclass' } = '';
634         @taxes = qsearch( 'cust_main_county', \%no_taxclass );
635       }
636     
637       $taxhash_elim{ shift(@elim) } = '';
638     } while ( !scalar(@taxes) && scalar(@elim) );
639
640     foreach my $tax_def (@taxes) {
641       my $taxnum = $tax_def->taxnum;
642       $taxnum_exemptions{$taxnum} ||= [];
643
644       # XXX do some kind of equivalent to set_exemptions here
645       # but for now just declare that there are no exemptions,
646       # and then hack the taxable amounts if the package def
647       # excludes setup/recur
648       $pkg->set('cust_tax_exempt_pkg', []);
649
650       if ( $part_item->setuptax or $tax_def->setuptax ) {
651         $pkg->set('unitsetup', 0);
652       }
653       if ( $part_item->recurtax or $tax_def->recurtax ) {
654         $pkg->set('unitrecur', 0);
655       }
656
657       my %taxline;
658       foreach my $pass (qw(first recur)) {
659         if ($pass eq 'recur') {
660           $pkg->set('unitsetup', 0);
661         }
662
663         my $taxline = $tax_def->taxline(
664           [ $pkg ],
665           exemptions => $taxnum_exemptions{$taxnum}
666         );
667         if ($taxline and !ref($taxline)) {
668           $dbh->rollback if $oldAutoCommit;
669           die "error calculating '".$tax_def->taxname .
670               "' for pkgpart '".$pkg->pkgpart."': $taxline\n";
671         }
672         $taxline{$pass} = $taxline;
673       }
674
675       my $quotation_pkg_tax = FS::quotation_pkg_tax->new({
676           quotationpkgnum => $pkg->quotationpkgnum,
677           itemdesc        => ($tax_def->taxname || 'Tax'),
678           taxnum          => $taxnum,
679           taxtype         => ref($tax_def),
680       });
681       my $setup_amount = 0;
682       my $recur_amount = 0;
683       if ($taxline{first}) {
684         $setup_amount = $taxline{first}->setup; # "first cycle", not setup
685       }
686       if ($taxline{recur}) {
687         $recur_amount = $taxline{recur}->setup;
688         $setup_amount -= $recur_amount; # to get the actual setup amount
689       }
690       if ( $recur_amount > 0 or $setup_amount > 0 ) {
691         $quotation_pkg_tax->set('setup_amount', sprintf('%.2f', $setup_amount));
692         $quotation_pkg_tax->set('recur_amount', sprintf('%.2f', $recur_amount));
693
694         my $error = $quotation_pkg_tax->insert;
695         if ($error) {
696           $dbh->rollback if $oldAutoCommit;
697           die "error recording '".$tax_def->taxname .
698               "' for pkgpart '".$pkg->pkgpart."': $error\n";
699         } # if $error
700       } # else there are no non-zero taxes; continue
701     } # foreach $tax_def
702   } # foreach $pkg
703
704   $dbh->commit if $oldAutoCommit;
705   '';
706 }
707
708 =back
709
710 =head1 CLASS METHODS
711
712 =over 4
713
714
715 =item search_sql_where HASHREF
716
717 Class method which returns an SQL WHERE fragment to search for parameters
718 specified in HASHREF.  Valid parameters are
719
720 =over 4
721
722 =item _date
723
724 List reference of start date, end date, as UNIX timestamps.
725
726 =item invnum_min
727
728 =item invnum_max
729
730 =item agentnum
731
732 =item charged
733
734 List reference of charged limits (exclusive).
735
736 =item owed
737
738 List reference of charged limits (exclusive).
739
740 =item open
741
742 flag, return open invoices only
743
744 =item net
745
746 flag, return net invoices only
747
748 =item days
749
750 =item newest_percust
751
752 =back
753
754 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
755
756 =cut
757
758 sub search_sql_where {
759   my($class, $param) = @_;
760   #if ( $DEBUG ) {
761   #  warn "$me search_sql_where called with params: \n".
762   #       join("\n", map { "  $_: ". $param->{$_} } keys %$param ). "\n";
763   #}
764
765   my @search = ();
766
767   #agentnum
768   if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
769     push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
770   }
771
772 #  #refnum
773 #  if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
774 #    push @search, "cust_main.refnum = $1";
775 #  }
776
777   #prospectnum
778   if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
779     push @search, "quotation.prospectnum = $1";
780   }
781
782   #custnum
783   if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
784     push @search, "cust_bill.custnum = $1";
785   }
786
787   #_date
788   if ( $param->{_date} ) {
789     my($beginning, $ending) = @{$param->{_date}};
790
791     push @search, "quotation._date >= $beginning",
792                   "quotation._date <  $ending";
793   }
794
795   #quotationnum
796   if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
797     push @search, "quotation.quotationnum >= $1";
798   }
799   if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
800     push @search, "quotation.quotationnum <= $1";
801   }
802
803 #  #charged
804 #  if ( $param->{charged} ) {
805 #    my @charged = ref($param->{charged})
806 #                    ? @{ $param->{charged} }
807 #                    : ($param->{charged});
808 #
809 #    push @search, map { s/^charged/cust_bill.charged/; $_; }
810 #                      @charged;
811 #  }
812
813   my $owed_sql = FS::cust_bill->owed_sql;
814
815   #days
816   push @search, "quotation._date < ". (time-86400*$param->{'days'})
817     if $param->{'days'};
818
819   #agent virtualization
820   my $curuser = $FS::CurrentUser::CurrentUser;
821   #false laziness w/search/quotation.html
822   push @search,' (    '. $curuser->agentnums_sql( table=>'prospect_main' ).
823                '   OR '. $curuser->agentnums_sql( table=>'cust_main' ).
824                ' )    ';
825
826   join(' AND ', @search );
827
828 }
829
830 =item _items_pkg
831
832 Return line item hashes for each package on this quotation. Differs from the
833 base L<FS::Template_Mixin> version in that it recalculates each quoted package
834 first, and doesn't implement the "condensed" option.
835
836 =cut
837
838 sub _items_pkg {
839   my ($self, %options) = @_;
840   $self->estimate;
841   # run it through the Template_Mixin engine
842   return $self->_items_cust_bill_pkg([ $self->quotation_pkg ], %options);
843 }
844
845 =back
846
847 =head1 BUGS
848
849 =head1 SEE ALSO
850
851 L<FS::Record>, schema.html from the base documentation.
852
853 =cut
854
855 1;
856