discounts + quotations, #33099
[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( qsearchs );
11 use FS::cust_main;
12 use FS::cust_pkg;
13 use FS::quotation_pkg;
14 use FS::type_pkgs;
15
16 =head1 NAME
17
18 FS::quotation - Object methods for quotation records
19
20 =head1 SYNOPSIS
21
22   use FS::quotation;
23
24   $record = new FS::quotation \%hash;
25   $record = new FS::quotation { 'column' => 'value' };
26
27   $error = $record->insert;
28
29   $error = $new_record->replace($old_record);
30
31   $error = $record->delete;
32
33   $error = $record->check;
34
35 =head1 DESCRIPTION
36
37 An FS::quotation object represents a quotation.  FS::quotation inherits from
38 FS::Record.  The following fields are currently supported:
39
40 =over 4
41
42 =item quotationnum
43
44 primary key
45
46 =item prospectnum
47
48 prospectnum
49
50 =item custnum
51
52 custnum
53
54 =item _date
55
56 _date
57
58 =item disabled
59
60 disabled
61
62 =item usernum
63
64 usernum
65
66
67 =back
68
69 =head1 METHODS
70
71 =over 4
72
73 =item new HASHREF
74
75 Creates a new quotation.  To add the quotation to the database, see L<"insert">.
76
77 Note that this stores the hash reference, not a distinct copy of the hash it
78 points to.  You can ask the object for a copy with the I<hash> method.
79
80 =cut
81
82 sub table { 'quotation'; }
83 sub notice_name { 'Quotation'; }
84 sub template_conf { 'quotation_'; }
85
86 =item insert
87
88 Adds this record to the database.  If there is an error, returns the error,
89 otherwise returns false.
90
91 =item delete
92
93 Delete this record from the database.
94
95 =item replace OLD_RECORD
96
97 Replaces the OLD_RECORD with this one in the database.  If there is an error,
98 returns the error, otherwise returns false.
99
100 =item check
101
102 Checks all fields to make sure this is a valid quotation.  If there is
103 an error, returns the error, otherwise returns false.  Called by the insert
104 and replace methods.
105
106 =cut
107
108 sub check {
109   my $self = shift;
110
111   my $error = 
112     $self->ut_numbern('quotationnum')
113     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum' )
114     || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum' )
115     || $self->ut_numbern('_date')
116     || $self->ut_enum('disabled', [ '', 'Y' ])
117     || $self->ut_numbern('usernum')
118   ;
119   return $error if $error;
120
121   $self->_date(time) unless $self->_date;
122
123   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
124
125   return 'prospectnum or custnum must be specified'
126     if ! $self->prospectnum
127     && ! $self->custnum;
128
129   $self->SUPER::check;
130 }
131
132 =item prospect_main
133
134 =item cust_main
135
136 =item cust_bill_pkg
137
138 =cut
139
140 sub cust_bill_pkg { #actually quotation_pkg objects
141   shift->quotation_pkg(@_);
142 }
143
144 =item total_setup
145
146 =cut
147
148 sub total_setup {
149   my $self = shift;
150   $self->_total('setup');
151 }
152
153 =item total_recur [ FREQ ]
154
155 =cut
156
157 sub total_recur {
158   my $self = shift;
159 #=item total_recur [ FREQ ]
160   #my $freq = @_ ? shift : '';
161   $self->_total('recur');
162 }
163
164 sub _total {
165   my( $self, $method ) = @_;
166
167   my $total = 0;
168   $total += $_->$method() for $self->cust_bill_pkg;
169   sprintf('%.2f', $total);
170
171 }
172
173 sub email {
174   my $self = shift;
175   my $opt = shift || {};
176   if ($opt and !ref($opt)) {
177     die ref($self). '->email called with positional parameters';
178   }
179
180   my $conf = $self->conf;
181
182   my $from = delete $opt->{from};
183
184   # this is where we set the From: address
185   $from ||= $conf->config('quotation_from', $self->cust_or_prospect->agentnum )
186         ||  $conf->invoice_from_full( $self->cust_or_prospect->agentnum );
187   $self->SUPER::email( {
188     'from' => $from,
189     %$opt,
190   });
191
192 }
193
194 sub email_subject {
195   my $self = shift;
196
197   my $subject =
198     $self->conf->config('quotation_subject') #, $self->cust_main->agentnum)
199       || 'Quotation';
200
201   #my $cust_main = $self->cust_main;
202   #my $name = $cust_main->name;
203   #my $name_short = $cust_main->name_short;
204   #my $invoice_number = $self->invnum;
205   #my $invoice_date = $self->_date_pretty;
206
207   eval qq("$subject");
208 }
209
210 =item cust_or_prosect
211
212 =cut
213
214 sub cust_or_prospect {
215   my $self = shift;
216   $self->custnum ? $self->cust_main : $self->prospect_main;
217 }
218
219 =item cust_or_prospect_label_link P
220
221 HTML links to either the customer or prospect.
222
223 Returns a list consisting of two elements.  The first is a text label for the
224 link, and the second is the URL.
225
226 =cut
227
228 sub cust_or_prospect_label_link {
229   my( $self, $p ) = @_;
230
231   if ( my $custnum = $self->custnum ) {
232     my $display_custnum = $self->cust_main->display_custnum;
233     my $target = $FS::CurrentUser::CurrentUser->default_customer_view eq 'jumbo'
234                    ? '#quotations'
235                    : ';show=quotations';
236     (
237       emt("View this customer (#[_1])",$display_custnum) =>
238         "${p}view/cust_main.cgi?custnum=$custnum$target"
239     );
240   } elsif ( my $prospectnum = $self->prospectnum ) {
241     (
242       emt("View this prospect (#[_1])",$prospectnum) =>
243         "${p}view/prospect_main.html?$prospectnum"
244     );
245   } else { #die?
246     ( '', '' );
247   }
248
249 }
250
251 #prevent things from falsely showing up as taxes, at least until we support
252 # quoting tax amounts..
253 sub _items_tax {
254   return ();
255 }
256 sub _items_nontax {
257   shift->cust_bill_pkg;
258 }
259
260 sub _items_total {
261   my( $self, $total_items ) = @_;
262
263   if ( $self->total_setup > 0 ) {
264     push @$total_items, {
265       'total_item'   => $self->mt( $self->total_recur > 0 ? 'Total Setup' : 'Total' ),
266       'total_amount' => $self->total_setup,
267     };
268   }
269
270   #could/should add up the different recurring frequencies on lines of their own
271   # but this will cover the 95% cases for now
272   if ( $self->total_recur > 0 ) {
273     push @$total_items, {
274       'total_item'   => $self->mt('Total Recurring'),
275       'total_amount' => $self->total_recur,
276     };
277   }
278
279 }
280
281 =item enable_previous
282
283 =cut
284
285 sub enable_previous { 0 }
286
287 =item convert_cust_main
288
289 If this quotation already belongs to a customer, then returns that customer, as
290 an FS::cust_main object.
291
292 Otherwise, creates a new customer (FS::cust_main object and record, and
293 associated) based on this quotation's prospect, then orders this quotation's
294 packages as real packages for the customer.
295
296 If there is an error, returns an error message, otherwise, returns the
297 newly-created FS::cust_main object.
298
299 =cut
300
301 sub convert_cust_main {
302   my $self = shift;
303
304   my $cust_main = $self->cust_main;
305   return $cust_main if $cust_main; #already converted, don't again
306
307   my $oldAutoCommit = $FS::UID::AutoCommit;
308   local $FS::UID::AutoCommit = 0;
309   my $dbh = dbh;
310
311   $cust_main = $self->prospect_main->convert_cust_main;
312   unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
313     $dbh->rollback if $oldAutoCommit;
314     return $cust_main;
315   }
316
317   $self->prospectnum('');
318   $self->custnum( $cust_main->custnum );
319   my $error = $self->replace || $self->order;
320   if ( $error ) {
321     $dbh->rollback if $oldAutoCommit;
322     return $error;
323   }
324
325   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
326
327   $cust_main;
328
329 }
330
331 =item order
332
333 This method is for use with quotations which are already associated with a customer.
334
335 Orders this quotation's packages as real packages for the customer.
336
337 If there is an error, returns an error message, otherwise returns false.
338
339 =cut
340
341 sub order {
342   my $self = shift;
343
344   tie my %cust_pkg, 'Tie::RefHash',
345     map { FS::cust_pkg->new({ pkgpart  => $_->pkgpart,
346                               quantity => $_->quantity,
347                            })
348             => [] #services
349         }
350       $self->quotation_pkg ;
351
352   $self->cust_main->order_pkgs( \%cust_pkg );
353
354 }
355
356 =item charge
357
358 One-time charges, like FS::cust_main::charge()
359
360 =cut
361
362 #super false laziness w/cust_main::charge
363 sub charge {
364   my $self = shift;
365   my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
366   my ( $pkg, $comment, $additional );
367   my ( $setuptax, $taxclass );   #internal taxes
368   my ( $taxproduct, $override ); #vendor (CCH) taxes
369   my $no_auto = '';
370   my $cust_pkg_ref = '';
371   my ( $bill_now, $invoice_terms ) = ( 0, '' );
372   my $locationnum;
373   if ( ref( $_[0] ) ) {
374     $amount     = $_[0]->{amount};
375     $setup_cost = $_[0]->{setup_cost};
376     $quantity   = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
377     $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
378     $no_auto    = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
379     $pkg        = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
380     $comment    = exists($_[0]->{comment}) ? $_[0]->{comment}
381                                            : '$'. sprintf("%.2f",$amount);
382     $setuptax   = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
383     $taxclass   = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
384     $classnum   = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
385     $additional = $_[0]->{additional} || [];
386     $taxproduct = $_[0]->{taxproductnum};
387     $override   = { '' => $_[0]->{tax_override} };
388     $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
389     $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
390     $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
391     $locationnum = $_[0]->{locationnum} || $self->ship_locationnum;
392   } else {
393     $amount     = shift;
394     $setup_cost = '';
395     $quantity   = 1;
396     $start_date = '';
397     $pkg        = @_ ? shift : 'One-time charge';
398     $comment    = @_ ? shift : '$'. sprintf("%.2f",$amount);
399     $setuptax   = '';
400     $taxclass   = @_ ? shift : '';
401     $additional = [];
402   }
403
404   local $SIG{HUP} = 'IGNORE';
405   local $SIG{INT} = 'IGNORE';
406   local $SIG{QUIT} = 'IGNORE';
407   local $SIG{TERM} = 'IGNORE';
408   local $SIG{TSTP} = 'IGNORE';
409   local $SIG{PIPE} = 'IGNORE';
410
411   my $oldAutoCommit = $FS::UID::AutoCommit;
412   local $FS::UID::AutoCommit = 0;
413   my $dbh = dbh;
414
415   my $part_pkg = new FS::part_pkg ( {
416     'pkg'           => $pkg,
417     'comment'       => $comment,
418     'plan'          => 'flat',
419     'freq'          => 0,
420     'disabled'      => 'Y',
421     'classnum'      => ( $classnum ? $classnum : '' ),
422     'setuptax'      => $setuptax,
423     'taxclass'      => $taxclass,
424     'taxproductnum' => $taxproduct,
425     'setup_cost'    => $setup_cost,
426   } );
427
428   my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
429                         ( 0 .. @$additional - 1 )
430                   ),
431                   'additional_count' => scalar(@$additional),
432                   'setup_fee' => $amount,
433                 );
434
435   my $error = $part_pkg->insert( options       => \%options,
436                                  tax_overrides => $override,
437                                );
438   if ( $error ) {
439     $dbh->rollback if $oldAutoCommit;
440     return $error;
441   }
442
443   my $pkgpart = $part_pkg->pkgpart;
444
445   #DIFF
446   my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
447
448   unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
449     my $type_pkgs = new FS::type_pkgs \%type_pkgs;
450     $error = $type_pkgs->insert;
451     if ( $error ) {
452       $dbh->rollback if $oldAutoCommit;
453       return $error;
454     }
455   }
456
457   #except for DIFF, eveything above is idential to cust_main version
458   #but below is our own thing pretty much (adding a quotation package instead
459   # of ordering a customer package, no "bill now")
460
461   my $quotation_pkg = new FS::quotation_pkg ( {
462     'quotationnum'  => $self->quotationnum,
463     'pkgpart'       => $pkgpart,
464     'quantity'      => $quantity,
465     #'start_date' => $start_date,
466     #'no_auto'    => $no_auto,
467     'locationnum'=> $locationnum,
468   } );
469
470   $error = $quotation_pkg->insert;
471   if ( $error ) {
472     $dbh->rollback if $oldAutoCommit;
473     return $error;
474   #} elsif ( $cust_pkg_ref ) {
475   #  ${$cust_pkg_ref} = $cust_pkg;
476   }
477
478   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
479   return '';
480
481 }
482
483 =item disable
484
485 Disables this quotation (sets disabled to Y, which hides the quotation on
486 prospects and customers).
487
488 If there is an error, returns an error message, otherwise returns false.
489
490 =cut
491
492 sub disable {
493   my $self = shift;
494   $self->disabled('Y');
495   $self->replace();
496 }
497
498 =item enable
499
500 Enables this quotation.
501
502 If there is an error, returns an error message, otherwise returns false.
503
504 =cut
505
506 sub enable {
507   my $self = shift;
508   $self->disabled('');
509   $self->replace();
510 }
511
512 =back
513
514 =head1 CLASS METHODS
515
516 =over 4
517
518
519 =item search_sql_where HASHREF
520
521 Class method which returns an SQL WHERE fragment to search for parameters
522 specified in HASHREF.  Valid parameters are
523
524 =over 4
525
526 =item _date
527
528 List reference of start date, end date, as UNIX timestamps.
529
530 =item invnum_min
531
532 =item invnum_max
533
534 =item agentnum
535
536 =item charged
537
538 List reference of charged limits (exclusive).
539
540 =item owed
541
542 List reference of charged limits (exclusive).
543
544 =item open
545
546 flag, return open invoices only
547
548 =item net
549
550 flag, return net invoices only
551
552 =item days
553
554 =item newest_percust
555
556 =back
557
558 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
559
560 =cut
561
562 sub search_sql_where {
563   my($class, $param) = @_;
564   #if ( $DEBUG ) {
565   #  warn "$me search_sql_where called with params: \n".
566   #       join("\n", map { "  $_: ". $param->{$_} } keys %$param ). "\n";
567   #}
568
569   my @search = ();
570
571   #agentnum
572   if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
573     push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
574   }
575
576 #  #refnum
577 #  if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
578 #    push @search, "cust_main.refnum = $1";
579 #  }
580
581   #prospectnum
582   if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
583     push @search, "quotation.prospectnum = $1";
584   }
585
586   #custnum
587   if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
588     push @search, "cust_bill.custnum = $1";
589   }
590
591   #_date
592   if ( $param->{_date} ) {
593     my($beginning, $ending) = @{$param->{_date}};
594
595     push @search, "quotation._date >= $beginning",
596                   "quotation._date <  $ending";
597   }
598
599   #quotationnum
600   if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
601     push @search, "quotation.quotationnum >= $1";
602   }
603   if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
604     push @search, "quotation.quotationnum <= $1";
605   }
606
607 #  #charged
608 #  if ( $param->{charged} ) {
609 #    my @charged = ref($param->{charged})
610 #                    ? @{ $param->{charged} }
611 #                    : ($param->{charged});
612 #
613 #    push @search, map { s/^charged/cust_bill.charged/; $_; }
614 #                      @charged;
615 #  }
616
617   my $owed_sql = FS::cust_bill->owed_sql;
618
619   #days
620   push @search, "quotation._date < ". (time-86400*$param->{'days'})
621     if $param->{'days'};
622
623   #agent virtualization
624   my $curuser = $FS::CurrentUser::CurrentUser;
625   #false laziness w/search/quotation.html
626   push @search,' (    '. $curuser->agentnums_sql( table=>'prospect_main' ).
627                '   OR '. $curuser->agentnums_sql( table=>'cust_main' ).
628                ' )    ';
629
630   join(' AND ', @search );
631
632 }
633
634 =item _items_pkg
635
636 Return line item hashes for each package on this quotation. Differs from the
637 base L<FS::Template_Mixin> version in that it recalculates each quoted package
638 first, and doesn't implement the "condensed" option.
639
640 =cut
641
642 sub _items_pkg {
643   my ($self, %options) = @_;
644   my @quotation_pkg = $self->quotation_pkg;
645   foreach (@quotation_pkg) {
646     my $error = $_->estimate;
647     die "error calculating estimate for pkgpart " . $_->pkgpart.": $error\n"
648       if $error;
649   }
650
651   # run it through the Template_Mixin engine
652   return $self->_items_cust_bill_pkg(\@quotation_pkg, %options);
653 }
654
655 =back
656
657 =head1 BUGS
658
659 =head1 SEE ALSO
660
661 L<FS::Record>, schema.html from the base documentation.
662
663 =cut
664
665 1;
666