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