Merge branch 'master' of git.freeside.biz:/home/git/freeside
[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 %all_cust_pkg, 'Tie::RefHash';
345   foreach my $quotation_pkg ($self->quotation_pkg) {
346     my $cust_pkg = FS::cust_pkg->new;
347     foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
348       $cust_pkg->set( $_, $quotation_pkg->get($_) );
349     }
350
351     # currently only one discount each
352     my ($pkg_discount) = $quotation_pkg->quotation_pkg_discount;
353     if ( $pkg_discount ) {
354       $cust_pkg->set('discountnum', $pkg_discount->discountnum);
355     }
356
357     $all_cust_pkg{$cust_pkg} = []; # no services
358   }
359
360   $self->cust_main->order_pkgs( \%all_cust_pkg );
361
362 }
363
364 =item charge
365
366 One-time charges, like FS::cust_main::charge()
367
368 =cut
369
370 #super false laziness w/cust_main::charge
371 sub charge {
372   my $self = shift;
373   my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
374   my ( $pkg, $comment, $additional );
375   my ( $setuptax, $taxclass );   #internal taxes
376   my ( $taxproduct, $override ); #vendor (CCH) taxes
377   my $no_auto = '';
378   my $cust_pkg_ref = '';
379   my ( $bill_now, $invoice_terms ) = ( 0, '' );
380   my $locationnum;
381   if ( ref( $_[0] ) ) {
382     $amount     = $_[0]->{amount};
383     $setup_cost = $_[0]->{setup_cost};
384     $quantity   = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
385     $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
386     $no_auto    = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
387     $pkg        = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
388     $comment    = exists($_[0]->{comment}) ? $_[0]->{comment}
389                                            : '$'. sprintf("%.2f",$amount);
390     $setuptax   = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
391     $taxclass   = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
392     $classnum   = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
393     $additional = $_[0]->{additional} || [];
394     $taxproduct = $_[0]->{taxproductnum};
395     $override   = { '' => $_[0]->{tax_override} };
396     $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
397     $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
398     $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
399     $locationnum = $_[0]->{locationnum} || $self->ship_locationnum;
400   } else {
401     $amount     = shift;
402     $setup_cost = '';
403     $quantity   = 1;
404     $start_date = '';
405     $pkg        = @_ ? shift : 'One-time charge';
406     $comment    = @_ ? shift : '$'. sprintf("%.2f",$amount);
407     $setuptax   = '';
408     $taxclass   = @_ ? shift : '';
409     $additional = [];
410   }
411
412   local $SIG{HUP} = 'IGNORE';
413   local $SIG{INT} = 'IGNORE';
414   local $SIG{QUIT} = 'IGNORE';
415   local $SIG{TERM} = 'IGNORE';
416   local $SIG{TSTP} = 'IGNORE';
417   local $SIG{PIPE} = 'IGNORE';
418
419   my $oldAutoCommit = $FS::UID::AutoCommit;
420   local $FS::UID::AutoCommit = 0;
421   my $dbh = dbh;
422
423   my $part_pkg = new FS::part_pkg ( {
424     'pkg'           => $pkg,
425     'comment'       => $comment,
426     'plan'          => 'flat',
427     'freq'          => 0,
428     'disabled'      => 'Y',
429     'classnum'      => ( $classnum ? $classnum : '' ),
430     'setuptax'      => $setuptax,
431     'taxclass'      => $taxclass,
432     'taxproductnum' => $taxproduct,
433     'setup_cost'    => $setup_cost,
434   } );
435
436   my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
437                         ( 0 .. @$additional - 1 )
438                   ),
439                   'additional_count' => scalar(@$additional),
440                   'setup_fee' => $amount,
441                 );
442
443   my $error = $part_pkg->insert( options       => \%options,
444                                  tax_overrides => $override,
445                                );
446   if ( $error ) {
447     $dbh->rollback if $oldAutoCommit;
448     return $error;
449   }
450
451   my $pkgpart = $part_pkg->pkgpart;
452
453   #DIFF
454   my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
455
456   unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
457     my $type_pkgs = new FS::type_pkgs \%type_pkgs;
458     $error = $type_pkgs->insert;
459     if ( $error ) {
460       $dbh->rollback if $oldAutoCommit;
461       return $error;
462     }
463   }
464
465   #except for DIFF, eveything above is idential to cust_main version
466   #but below is our own thing pretty much (adding a quotation package instead
467   # of ordering a customer package, no "bill now")
468
469   my $quotation_pkg = new FS::quotation_pkg ( {
470     'quotationnum'  => $self->quotationnum,
471     'pkgpart'       => $pkgpart,
472     'quantity'      => $quantity,
473     #'start_date' => $start_date,
474     #'no_auto'    => $no_auto,
475     'locationnum'=> $locationnum,
476   } );
477
478   $error = $quotation_pkg->insert;
479   if ( $error ) {
480     $dbh->rollback if $oldAutoCommit;
481     return $error;
482   #} elsif ( $cust_pkg_ref ) {
483   #  ${$cust_pkg_ref} = $cust_pkg;
484   }
485
486   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
487   return '';
488
489 }
490
491 =item disable
492
493 Disables this quotation (sets disabled to Y, which hides the quotation on
494 prospects and customers).
495
496 If there is an error, returns an error message, otherwise returns false.
497
498 =cut
499
500 sub disable {
501   my $self = shift;
502   $self->disabled('Y');
503   $self->replace();
504 }
505
506 =item enable
507
508 Enables this quotation.
509
510 If there is an error, returns an error message, otherwise returns false.
511
512 =cut
513
514 sub enable {
515   my $self = shift;
516   $self->disabled('');
517   $self->replace();
518 }
519
520 =back
521
522 =head1 CLASS METHODS
523
524 =over 4
525
526
527 =item search_sql_where HASHREF
528
529 Class method which returns an SQL WHERE fragment to search for parameters
530 specified in HASHREF.  Valid parameters are
531
532 =over 4
533
534 =item _date
535
536 List reference of start date, end date, as UNIX timestamps.
537
538 =item invnum_min
539
540 =item invnum_max
541
542 =item agentnum
543
544 =item charged
545
546 List reference of charged limits (exclusive).
547
548 =item owed
549
550 List reference of charged limits (exclusive).
551
552 =item open
553
554 flag, return open invoices only
555
556 =item net
557
558 flag, return net invoices only
559
560 =item days
561
562 =item newest_percust
563
564 =back
565
566 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
567
568 =cut
569
570 sub search_sql_where {
571   my($class, $param) = @_;
572   #if ( $DEBUG ) {
573   #  warn "$me search_sql_where called with params: \n".
574   #       join("\n", map { "  $_: ". $param->{$_} } keys %$param ). "\n";
575   #}
576
577   my @search = ();
578
579   #agentnum
580   if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
581     push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
582   }
583
584 #  #refnum
585 #  if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
586 #    push @search, "cust_main.refnum = $1";
587 #  }
588
589   #prospectnum
590   if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
591     push @search, "quotation.prospectnum = $1";
592   }
593
594   #custnum
595   if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
596     push @search, "cust_bill.custnum = $1";
597   }
598
599   #_date
600   if ( $param->{_date} ) {
601     my($beginning, $ending) = @{$param->{_date}};
602
603     push @search, "quotation._date >= $beginning",
604                   "quotation._date <  $ending";
605   }
606
607   #quotationnum
608   if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
609     push @search, "quotation.quotationnum >= $1";
610   }
611   if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
612     push @search, "quotation.quotationnum <= $1";
613   }
614
615 #  #charged
616 #  if ( $param->{charged} ) {
617 #    my @charged = ref($param->{charged})
618 #                    ? @{ $param->{charged} }
619 #                    : ($param->{charged});
620 #
621 #    push @search, map { s/^charged/cust_bill.charged/; $_; }
622 #                      @charged;
623 #  }
624
625   my $owed_sql = FS::cust_bill->owed_sql;
626
627   #days
628   push @search, "quotation._date < ". (time-86400*$param->{'days'})
629     if $param->{'days'};
630
631   #agent virtualization
632   my $curuser = $FS::CurrentUser::CurrentUser;
633   #false laziness w/search/quotation.html
634   push @search,' (    '. $curuser->agentnums_sql( table=>'prospect_main' ).
635                '   OR '. $curuser->agentnums_sql( table=>'cust_main' ).
636                ' )    ';
637
638   join(' AND ', @search );
639
640 }
641
642 =item _items_pkg
643
644 Return line item hashes for each package on this quotation. Differs from the
645 base L<FS::Template_Mixin> version in that it recalculates each quoted package
646 first, and doesn't implement the "condensed" option.
647
648 =cut
649
650 sub _items_pkg {
651   my ($self, %options) = @_;
652   my @quotation_pkg = $self->quotation_pkg;
653   foreach (@quotation_pkg) {
654     my $error = $_->estimate;
655     die "error calculating estimate for pkgpart " . $_->pkgpart.": $error\n"
656       if $error;
657   }
658
659   # run it through the Template_Mixin engine
660   return $self->_items_cust_bill_pkg(\@quotation_pkg, %options);
661 }
662
663 =back
664
665 =head1 BUGS
666
667 =head1 SEE ALSO
668
669 L<FS::Record>, schema.html from the base documentation.
670
671 =cut
672
673 1;
674