payment total on quotes: not when recur_method eq prorate, and be more refined about...
[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     # show 'first payment' line (setup + recur) if there are no prorated 
346     # packages included
347     my $disable_total = 0;
348     foreach my $quotation_pkg ($self->quotation_pkg) {
349       my $part_pkg = $quotation_pkg->part_pkg;
350       if (    $part_pkg->plan =~ /^(prorate|torrus|agent$)/
351            || $part_pkg->option('recur_method') eq 'prorate'
352            || ( $part_pkg->option('sync_bill_date')
353                   && $self->custnum
354                   && $self->cust_main->billing_pkgs #num_billing_pkgs when we have it
355               )
356       ) {
357         $disable_total = 1;
358         last;
359       }
360     }
361     if (!$disable_total) {
362       push @items, {
363         'total_item'   => $self->mt('First payment'),
364         'total_amount' => sprintf('%.2f', $total_setup + $total_recur),
365         'break_after'  => 1,
366       };
367     }
368   }
369
370   return @items;
371
372 }
373
374 =item enable_previous
375
376 =cut
377
378 sub enable_previous { 0 }
379
380 =item convert_cust_main
381
382 If this quotation already belongs to a customer, then returns that customer, as
383 an FS::cust_main object.
384
385 Otherwise, creates a new customer (FS::cust_main object and record, and
386 associated) based on this quotation's prospect, then orders this quotation's
387 packages as real packages for the customer.
388
389 If there is an error, returns an error message, otherwise, returns the
390 newly-created FS::cust_main object.
391
392 =cut
393
394 sub convert_cust_main {
395   my $self = shift;
396
397   my $cust_main = $self->cust_main;
398   return $cust_main if $cust_main; #already converted, don't again
399
400   my $oldAutoCommit = $FS::UID::AutoCommit;
401   local $FS::UID::AutoCommit = 0;
402   my $dbh = dbh;
403
404   $cust_main = $self->prospect_main->convert_cust_main;
405   unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
406     $dbh->rollback if $oldAutoCommit;
407     return $cust_main;
408   }
409
410   $self->prospectnum('');
411   $self->custnum( $cust_main->custnum );
412   my $error = $self->replace || $self->order;
413   if ( $error ) {
414     $dbh->rollback if $oldAutoCommit;
415     return $error;
416   }
417
418   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
419
420   $cust_main;
421
422 }
423
424 =item order
425
426 This method is for use with quotations which are already associated with a customer.
427
428 Orders this quotation's packages as real packages for the customer.
429
430 If there is an error, returns an error message, otherwise returns false.
431
432 =cut
433
434 sub order {
435   my $self = shift;
436
437   tie my %all_cust_pkg, 'Tie::RefHash';
438   foreach my $quotation_pkg ($self->quotation_pkg) {
439     my $cust_pkg = FS::cust_pkg->new;
440     foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
441       $cust_pkg->set( $_, $quotation_pkg->get($_) );
442     }
443
444     # currently only one discount each
445     my ($pkg_discount) = $quotation_pkg->quotation_pkg_discount;
446     if ( $pkg_discount ) {
447       $cust_pkg->set('discountnum', $pkg_discount->discountnum);
448     }
449
450     $all_cust_pkg{$cust_pkg} = []; # no services
451   }
452
453   $self->cust_main->order_pkgs( \%all_cust_pkg );
454
455 }
456
457 =item quotation_pkg
458
459 =cut
460
461 sub quotation_pkg {
462   my $self = shift;
463   qsearch('quotation_pkg', { 'quotationnum' => $self->quotationnum } );
464 }
465
466 =item charge
467
468 One-time charges, like FS::cust_main::charge()
469
470 =cut
471
472 #super false laziness w/cust_main::charge
473 sub charge {
474   my $self = shift;
475   my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
476   my ( $pkg, $comment, $additional );
477   my ( $setuptax, $taxclass );   #internal taxes
478   my ( $taxproduct, $override ); #vendor (CCH) taxes
479   my $no_auto = '';
480   my $cust_pkg_ref = '';
481   my ( $bill_now, $invoice_terms ) = ( 0, '' );
482   my $locationnum;
483   if ( ref( $_[0] ) ) {
484     $amount     = $_[0]->{amount};
485     $setup_cost = $_[0]->{setup_cost};
486     $quantity   = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
487     $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
488     $no_auto    = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
489     $pkg        = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
490     $comment    = exists($_[0]->{comment}) ? $_[0]->{comment}
491                                            : '$'. sprintf("%.2f",$amount);
492     $setuptax   = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
493     $taxclass   = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
494     $classnum   = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
495     $additional = $_[0]->{additional} || [];
496     $taxproduct = $_[0]->{taxproductnum};
497     $override   = { '' => $_[0]->{tax_override} };
498     $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
499     $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
500     $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
501     $locationnum = $_[0]->{locationnum};
502   } else {
503     $amount     = shift;
504     $setup_cost = '';
505     $quantity   = 1;
506     $start_date = '';
507     $pkg        = @_ ? shift : 'One-time charge';
508     $comment    = @_ ? shift : '$'. sprintf("%.2f",$amount);
509     $setuptax   = '';
510     $taxclass   = @_ ? shift : '';
511     $additional = [];
512   }
513
514   local $SIG{HUP} = 'IGNORE';
515   local $SIG{INT} = 'IGNORE';
516   local $SIG{QUIT} = 'IGNORE';
517   local $SIG{TERM} = 'IGNORE';
518   local $SIG{TSTP} = 'IGNORE';
519   local $SIG{PIPE} = 'IGNORE';
520
521   my $oldAutoCommit = $FS::UID::AutoCommit;
522   local $FS::UID::AutoCommit = 0;
523   my $dbh = dbh;
524
525   my $part_pkg = new FS::part_pkg ( {
526     'pkg'           => $pkg,
527     'comment'       => $comment,
528     'plan'          => 'flat',
529     'freq'          => 0,
530     'disabled'      => 'Y',
531     'classnum'      => ( $classnum ? $classnum : '' ),
532     'setuptax'      => $setuptax,
533     'taxclass'      => $taxclass,
534     'taxproductnum' => $taxproduct,
535     'setup_cost'    => $setup_cost,
536   } );
537
538   my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
539                         ( 0 .. @$additional - 1 )
540                   ),
541                   'additional_count' => scalar(@$additional),
542                   'setup_fee' => $amount,
543                 );
544
545   my $error = $part_pkg->insert( options       => \%options,
546                                  tax_overrides => $override,
547                                );
548   if ( $error ) {
549     $dbh->rollback if $oldAutoCommit;
550     return $error;
551   }
552
553   my $pkgpart = $part_pkg->pkgpart;
554
555   #DIFF
556   my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
557
558   unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
559     my $type_pkgs = new FS::type_pkgs \%type_pkgs;
560     $error = $type_pkgs->insert;
561     if ( $error ) {
562       $dbh->rollback if $oldAutoCommit;
563       return $error;
564     }
565   }
566
567   #except for DIFF, eveything above is idential to cust_main version
568   #but below is our own thing pretty much (adding a quotation package instead
569   # of ordering a customer package, no "bill now")
570
571   my $quotation_pkg = new FS::quotation_pkg ( {
572     'quotationnum'  => $self->quotationnum,
573     'pkgpart'       => $pkgpart,
574     'quantity'      => $quantity,
575     #'start_date' => $start_date,
576     #'no_auto'    => $no_auto,
577     'locationnum'=> $locationnum,
578   } );
579
580   $error = $quotation_pkg->insert;
581   if ( $error ) {
582     $dbh->rollback if $oldAutoCommit;
583     return $error;
584   #} elsif ( $cust_pkg_ref ) {
585   #  ${$cust_pkg_ref} = $cust_pkg;
586   }
587
588   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
589   return '';
590
591 }
592
593 =item disable
594
595 Disables this quotation (sets disabled to Y, which hides the quotation on
596 prospects and customers).
597
598 If there is an error, returns an error message, otherwise returns false.
599
600 =cut
601
602 sub disable {
603   my $self = shift;
604   $self->disabled('Y');
605   $self->replace();
606 }
607
608 =item enable
609
610 Enables this quotation.
611
612 If there is an error, returns an error message, otherwise returns false.
613
614 =cut
615
616 sub enable {
617   my $self = shift;
618   $self->disabled('');
619   $self->replace();
620 }
621
622 =item estimate
623
624 Calculates current prices for all items on this quotation, including 
625 discounts and taxes, and updates the quotation_pkg records accordingly.
626
627 =cut
628
629 sub estimate {
630   my $self = shift;
631   my $conf = FS::Conf->new;
632
633   my $dbh = dbh;
634   my $oldAutoCommit = $FS::UID::AutoCommit;
635   local $FS::UID::AutoCommit = 0;
636
637   # bring individual items up to date (set setup/recur and discounts)
638   my @quotation_pkg = $self->quotation_pkg;
639   foreach my $pkg (@quotation_pkg) {
640     my $error = $pkg->estimate;
641     if ($error) {
642       $dbh->rollback if $oldAutoCommit;
643       die "error calculating estimate for pkgpart " . $pkg->pkgpart.": $error\n";
644     }
645
646     # delete old tax records
647     foreach my $quotation_pkg_tax ($pkg->quotation_pkg_tax) {
648       $error = $quotation_pkg_tax->delete;
649       if ( $error ) {
650         $dbh->rollback if $oldAutoCommit;
651         die "error flushing tax records for pkgpart ". $pkg->pkgpart.": $error\n";
652       }
653     }
654   }
655
656   # annoyingly duplicates handle_taxes--fix this in 4.x 
657   if ( $conf->exists('enable_taxproducts') ) {
658     warn "can't calculate external taxes for quotations yet\n";
659     # then we're done
660     return;
661   }
662
663   my %taxnum_exemptions; # for monthly exemptions; as yet unused
664
665   foreach my $pkg (@quotation_pkg) {
666     my $location = $pkg->cust_location;
667
668     my $part_item = $pkg->part_pkg; # we don't have fees on these yet
669     my @loc_keys = qw( district city county state country);
670     my %taxhash = map { $_ => $location->$_ } @loc_keys;
671     $taxhash{'taxclass'} = $part_item->taxclass;
672     my @taxes;
673     my %taxhash_elim = %taxhash;
674     my @elim = qw( district city county state );
675     do {
676       @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
677       if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
678         #then try a match without taxclass
679         my %no_taxclass = %taxhash_elim;
680         $no_taxclass{ 'taxclass' } = '';
681         @taxes = qsearch( 'cust_main_county', \%no_taxclass );
682       }
683     
684       $taxhash_elim{ shift(@elim) } = '';
685     } while ( !scalar(@taxes) && scalar(@elim) );
686
687     foreach my $tax_def (@taxes) {
688       my $taxnum = $tax_def->taxnum;
689       $taxnum_exemptions{$taxnum} ||= [];
690
691       # XXX do some kind of equivalent to set_exemptions here
692       # but for now just declare that there are no exemptions,
693       # and then hack the taxable amounts if the package def
694       # excludes setup/recur
695       $pkg->set('cust_tax_exempt_pkg', []);
696
697       if ( $part_item->setuptax or $tax_def->setuptax ) {
698         $pkg->set('unitsetup', 0);
699       }
700       if ( $part_item->recurtax or $tax_def->recurtax ) {
701         $pkg->set('unitrecur', 0);
702       }
703
704       my %taxline;
705       foreach my $pass (qw(first recur)) {
706         if ($pass eq 'recur') {
707           $pkg->set('unitsetup', 0);
708         }
709
710         my $taxline = $tax_def->taxline(
711           [ $pkg ],
712           exemptions => $taxnum_exemptions{$taxnum}
713         );
714         if ($taxline and !ref($taxline)) {
715           $dbh->rollback if $oldAutoCommit;
716           die "error calculating '".$tax_def->taxname .
717               "' for pkgpart '".$pkg->pkgpart."': $taxline\n";
718         }
719         $taxline{$pass} = $taxline;
720       }
721
722       my $quotation_pkg_tax = FS::quotation_pkg_tax->new({
723           quotationpkgnum => $pkg->quotationpkgnum,
724           itemdesc        => ($tax_def->taxname || 'Tax'),
725           taxnum          => $taxnum,
726           taxtype         => ref($tax_def),
727       });
728       my $setup_amount = 0;
729       my $recur_amount = 0;
730       if ($taxline{first}) {
731         $setup_amount = $taxline{first}->setup; # "first cycle", not setup
732       }
733       if ($taxline{recur}) {
734         $recur_amount = $taxline{recur}->setup;
735         $setup_amount -= $recur_amount; # to get the actual setup amount
736       }
737       if ( $recur_amount > 0 or $setup_amount > 0 ) {
738         $quotation_pkg_tax->set('setup_amount', sprintf('%.2f', $setup_amount));
739         $quotation_pkg_tax->set('recur_amount', sprintf('%.2f', $recur_amount));
740
741         my $error = $quotation_pkg_tax->insert;
742         if ($error) {
743           $dbh->rollback if $oldAutoCommit;
744           die "error recording '".$tax_def->taxname .
745               "' for pkgpart '".$pkg->pkgpart."': $error\n";
746         } # if $error
747       } # else there are no non-zero taxes; continue
748     } # foreach $tax_def
749   } # foreach $pkg
750
751   $dbh->commit if $oldAutoCommit;
752   '';
753 }
754
755 =back
756
757 =head1 CLASS METHODS
758
759 =over 4
760
761
762 =item search_sql_where HASHREF
763
764 Class method which returns an SQL WHERE fragment to search for parameters
765 specified in HASHREF.  Valid parameters are
766
767 =over 4
768
769 =item _date
770
771 List reference of start date, end date, as UNIX timestamps.
772
773 =item invnum_min
774
775 =item invnum_max
776
777 =item agentnum
778
779 =item charged
780
781 List reference of charged limits (exclusive).
782
783 =item owed
784
785 List reference of charged limits (exclusive).
786
787 =item open
788
789 flag, return open invoices only
790
791 =item net
792
793 flag, return net invoices only
794
795 =item days
796
797 =item newest_percust
798
799 =back
800
801 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
802
803 =cut
804
805 sub search_sql_where {
806   my($class, $param) = @_;
807   #if ( $DEBUG ) {
808   #  warn "$me search_sql_where called with params: \n".
809   #       join("\n", map { "  $_: ". $param->{$_} } keys %$param ). "\n";
810   #}
811
812   my @search = ();
813
814   #agentnum
815   if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
816     push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
817   }
818
819 #  #refnum
820 #  if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
821 #    push @search, "cust_main.refnum = $1";
822 #  }
823
824   #prospectnum
825   if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
826     push @search, "quotation.prospectnum = $1";
827   }
828
829   #custnum
830   if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
831     push @search, "cust_bill.custnum = $1";
832   }
833
834   #_date
835   if ( $param->{_date} ) {
836     my($beginning, $ending) = @{$param->{_date}};
837
838     push @search, "quotation._date >= $beginning",
839                   "quotation._date <  $ending";
840   }
841
842   #quotationnum
843   if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
844     push @search, "quotation.quotationnum >= $1";
845   }
846   if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
847     push @search, "quotation.quotationnum <= $1";
848   }
849
850 #  #charged
851 #  if ( $param->{charged} ) {
852 #    my @charged = ref($param->{charged})
853 #                    ? @{ $param->{charged} }
854 #                    : ($param->{charged});
855 #
856 #    push @search, map { s/^charged/cust_bill.charged/; $_; }
857 #                      @charged;
858 #  }
859
860   my $owed_sql = FS::cust_bill->owed_sql;
861
862   #days
863   push @search, "quotation._date < ". (time-86400*$param->{'days'})
864     if $param->{'days'};
865
866   #agent virtualization
867   my $curuser = $FS::CurrentUser::CurrentUser;
868   #false laziness w/search/quotation.html
869   push @search,' (    '. $curuser->agentnums_sql( table=>'prospect_main' ).
870                '   OR '. $curuser->agentnums_sql( table=>'cust_main' ).
871                ' )    ';
872
873   join(' AND ', @search );
874
875 }
876
877 =item _items_pkg
878
879 Return line item hashes for each package on this quotation. Differs from the
880 base L<FS::Template_Mixin> version in that it recalculates each quoted package
881 first, and doesn't implement the "condensed" option.
882
883 =cut
884
885 sub _items_pkg {
886   my ($self, %options) = @_;
887   $self->estimate;
888   # run it through the Template_Mixin engine
889   return $self->_items_cust_bill_pkg([ $self->quotation_pkg ], %options);
890 }
891
892 =back
893
894 =head1 BUGS
895
896 =head1 SEE ALSO
897
898 L<FS::Record>, schema.html from the base documentation.
899
900 =cut
901
902 1;
903