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->config('invoice_from_name', $self->cust_or_prospect->agentnum ) ?
187             $conf->config('invoice_from_name', $self->cust_or_prospect->agentnum ) . ' <' .
188             $conf->config('invoice_from', $self->cust_or_prospect->agentnum ) . '>' :
189             $conf->config('invoice_from', $self->cust_or_prospect->agentnum ));
190   $self->SUPER::email( {
191     'from' => $from,
192     %$opt,
193   });
194
195 }
196
197 sub email_subject {
198   my $self = shift;
199
200   my $subject =
201     $self->conf->config('quotation_subject') #, $self->cust_main->agentnum)
202       || 'Quotation';
203
204   #my $cust_main = $self->cust_main;
205   #my $name = $cust_main->name;
206   #my $name_short = $cust_main->name_short;
207   #my $invoice_number = $self->invnum;
208   #my $invoice_date = $self->_date_pretty;
209
210   eval qq("$subject");
211 }
212
213 =item cust_or_prosect
214
215 =cut
216
217 sub cust_or_prospect {
218   my $self = shift;
219   $self->custnum ? $self->cust_main : $self->prospect_main;
220 }
221
222 =item cust_or_prospect_label_link P
223
224 HTML links to either the customer or prospect.
225
226 Returns a list consisting of two elements.  The first is a text label for the
227 link, and the second is the URL.
228
229 =cut
230
231 sub cust_or_prospect_label_link {
232   my( $self, $p ) = @_;
233
234   if ( my $custnum = $self->custnum ) {
235     my $display_custnum = $self->cust_main->display_custnum;
236     my $target = $FS::CurrentUser::CurrentUser->default_customer_view eq 'jumbo'
237                    ? '#quotations'
238                    : ';show=quotations';
239     (
240       emt("View this customer (#[_1])",$display_custnum) =>
241         "${p}view/cust_main.cgi?custnum=$custnum$target"
242     );
243   } elsif ( my $prospectnum = $self->prospectnum ) {
244     (
245       emt("View this prospect (#[_1])",$prospectnum) =>
246         "${p}view/prospect_main.html?$prospectnum"
247     );
248   } else { #die?
249     ( '', '' );
250   }
251
252 }
253
254 #prevent things from falsely showing up as taxes, at least until we support
255 # quoting tax amounts..
256 sub _items_tax {
257   return ();
258 }
259 sub _items_nontax {
260   shift->cust_bill_pkg;
261 }
262
263 sub _items_total {
264   my( $self, $total_items ) = @_;
265
266   if ( $self->total_setup > 0 ) {
267     push @$total_items, {
268       'total_item'   => $self->mt( $self->total_recur > 0 ? 'Total Setup' : 'Total' ),
269       'total_amount' => $self->total_setup,
270     };
271   }
272
273   #could/should add up the different recurring frequencies on lines of their own
274   # but this will cover the 95% cases for now
275   if ( $self->total_recur > 0 ) {
276     push @$total_items, {
277       'total_item'   => $self->mt('Total Recurring'),
278       'total_amount' => $self->total_recur,
279     };
280   }
281
282 }
283
284 =item enable_previous
285
286 =cut
287
288 sub enable_previous { 0 }
289
290 =item convert_cust_main
291
292 If this quotation already belongs to a customer, then returns that customer, as
293 an FS::cust_main object.
294
295 Otherwise, creates a new customer (FS::cust_main object and record, and
296 associated) based on this quotation's prospect, then orders this quotation's
297 packages as real packages for the customer.
298
299 If there is an error, returns an error message, otherwise, returns the
300 newly-created FS::cust_main object.
301
302 =cut
303
304 sub convert_cust_main {
305   my $self = shift;
306
307   my $cust_main = $self->cust_main;
308   return $cust_main if $cust_main; #already converted, don't again
309
310   my $oldAutoCommit = $FS::UID::AutoCommit;
311   local $FS::UID::AutoCommit = 0;
312   my $dbh = dbh;
313
314   $cust_main = $self->prospect_main->convert_cust_main;
315   unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
316     $dbh->rollback if $oldAutoCommit;
317     return $cust_main;
318   }
319
320   $self->prospectnum('');
321   $self->custnum( $cust_main->custnum );
322   my $error = $self->replace || $self->order;
323   if ( $error ) {
324     $dbh->rollback if $oldAutoCommit;
325     return $error;
326   }
327
328   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
329
330   $cust_main;
331
332 }
333
334 =item order
335
336 This method is for use with quotations which are already associated with a customer.
337
338 Orders this quotation's packages as real packages for the customer.
339
340 If there is an error, returns an error message, otherwise returns false.
341
342 =cut
343
344 sub order {
345   my $self = shift;
346
347   tie my %cust_pkg, 'Tie::RefHash',
348     map { FS::cust_pkg->new({ pkgpart  => $_->pkgpart,
349                               quantity => $_->quantity,
350                            })
351             => [] #services
352         }
353       $self->quotation_pkg ;
354
355   $self->cust_main->order_pkgs( \%cust_pkg );
356
357 }
358
359 =item charge
360
361 One-time charges, like FS::cust_main::charge()
362
363 =cut
364
365 #super false laziness w/cust_main::charge
366 sub charge {
367   my $self = shift;
368   my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
369   my ( $pkg, $comment, $additional );
370   my ( $setuptax, $taxclass );   #internal taxes
371   my ( $taxproduct, $override ); #vendor (CCH) taxes
372   my $no_auto = '';
373   my $cust_pkg_ref = '';
374   my ( $bill_now, $invoice_terms ) = ( 0, '' );
375   my $locationnum;
376   if ( ref( $_[0] ) ) {
377     $amount     = $_[0]->{amount};
378     $setup_cost = $_[0]->{setup_cost};
379     $quantity   = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
380     $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
381     $no_auto    = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
382     $pkg        = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
383     $comment    = exists($_[0]->{comment}) ? $_[0]->{comment}
384                                            : '$'. sprintf("%.2f",$amount);
385     $setuptax   = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
386     $taxclass   = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
387     $classnum   = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
388     $additional = $_[0]->{additional} || [];
389     $taxproduct = $_[0]->{taxproductnum};
390     $override   = { '' => $_[0]->{tax_override} };
391     $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
392     $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
393     $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
394     $locationnum = $_[0]->{locationnum} || $self->ship_locationnum;
395   } else {
396     $amount     = shift;
397     $setup_cost = '';
398     $quantity   = 1;
399     $start_date = '';
400     $pkg        = @_ ? shift : 'One-time charge';
401     $comment    = @_ ? shift : '$'. sprintf("%.2f",$amount);
402     $setuptax   = '';
403     $taxclass   = @_ ? shift : '';
404     $additional = [];
405   }
406
407   local $SIG{HUP} = 'IGNORE';
408   local $SIG{INT} = 'IGNORE';
409   local $SIG{QUIT} = 'IGNORE';
410   local $SIG{TERM} = 'IGNORE';
411   local $SIG{TSTP} = 'IGNORE';
412   local $SIG{PIPE} = 'IGNORE';
413
414   my $oldAutoCommit = $FS::UID::AutoCommit;
415   local $FS::UID::AutoCommit = 0;
416   my $dbh = dbh;
417
418   my $part_pkg = new FS::part_pkg ( {
419     'pkg'           => $pkg,
420     'comment'       => $comment,
421     'plan'          => 'flat',
422     'freq'          => 0,
423     'disabled'      => 'Y',
424     'classnum'      => ( $classnum ? $classnum : '' ),
425     'setuptax'      => $setuptax,
426     'taxclass'      => $taxclass,
427     'taxproductnum' => $taxproduct,
428     'setup_cost'    => $setup_cost,
429   } );
430
431   my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
432                         ( 0 .. @$additional - 1 )
433                   ),
434                   'additional_count' => scalar(@$additional),
435                   'setup_fee' => $amount,
436                 );
437
438   my $error = $part_pkg->insert( options       => \%options,
439                                  tax_overrides => $override,
440                                );
441   if ( $error ) {
442     $dbh->rollback if $oldAutoCommit;
443     return $error;
444   }
445
446   my $pkgpart = $part_pkg->pkgpart;
447
448   #DIFF
449   my %type_pkgs = ( 'typenum' => $self->cust_or_prospect->agent->typenum, 'pkgpart' => $pkgpart );
450
451   unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
452     my $type_pkgs = new FS::type_pkgs \%type_pkgs;
453     $error = $type_pkgs->insert;
454     if ( $error ) {
455       $dbh->rollback if $oldAutoCommit;
456       return $error;
457     }
458   }
459
460   #except for DIFF, eveything above is idential to cust_main version
461   #but below is our own thing pretty much (adding a quotation package instead
462   # of ordering a customer package, no "bill now")
463
464   my $quotation_pkg = new FS::quotation_pkg ( {
465     'quotationnum'  => $self->quotationnum,
466     'pkgpart'       => $pkgpart,
467     'quantity'      => $quantity,
468     #'start_date' => $start_date,
469     #'no_auto'    => $no_auto,
470     'locationnum'=> $locationnum,
471   } );
472
473   $error = $quotation_pkg->insert;
474   if ( $error ) {
475     $dbh->rollback if $oldAutoCommit;
476     return $error;
477   #} elsif ( $cust_pkg_ref ) {
478   #  ${$cust_pkg_ref} = $cust_pkg;
479   }
480
481   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
482   return '';
483
484 }
485
486 =item disable
487
488 Disables this quotation (sets disabled to Y, which hides the quotation on
489 prospects and customers).
490
491 If there is an error, returns an error message, otherwise returns false.
492
493 =cut
494
495 sub disable {
496   my $self = shift;
497   $self->disabled('Y');
498   $self->replace();
499 }
500
501 =item enable
502
503 Enables this quotation.
504
505 If there is an error, returns an error message, otherwise returns false.
506
507 =cut
508
509 sub enable {
510   my $self = shift;
511   $self->disabled('');
512   $self->replace();
513 }
514
515 =back
516
517 =head1 CLASS METHODS
518
519 =over 4
520
521
522 =item search_sql_where HASHREF
523
524 Class method which returns an SQL WHERE fragment to search for parameters
525 specified in HASHREF.  Valid parameters are
526
527 =over 4
528
529 =item _date
530
531 List reference of start date, end date, as UNIX timestamps.
532
533 =item invnum_min
534
535 =item invnum_max
536
537 =item agentnum
538
539 =item charged
540
541 List reference of charged limits (exclusive).
542
543 =item owed
544
545 List reference of charged limits (exclusive).
546
547 =item open
548
549 flag, return open invoices only
550
551 =item net
552
553 flag, return net invoices only
554
555 =item days
556
557 =item newest_percust
558
559 =back
560
561 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
562
563 =cut
564
565 sub search_sql_where {
566   my($class, $param) = @_;
567   #if ( $DEBUG ) {
568   #  warn "$me search_sql_where called with params: \n".
569   #       join("\n", map { "  $_: ". $param->{$_} } keys %$param ). "\n";
570   #}
571
572   my @search = ();
573
574   #agentnum
575   if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
576     push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
577   }
578
579 #  #refnum
580 #  if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
581 #    push @search, "cust_main.refnum = $1";
582 #  }
583
584   #prospectnum
585   if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
586     push @search, "quotation.prospectnum = $1";
587   }
588
589   #custnum
590   if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
591     push @search, "cust_bill.custnum = $1";
592   }
593
594   #_date
595   if ( $param->{_date} ) {
596     my($beginning, $ending) = @{$param->{_date}};
597
598     push @search, "quotation._date >= $beginning",
599                   "quotation._date <  $ending";
600   }
601
602   #quotationnum
603   if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
604     push @search, "quotation.quotationnum >= $1";
605   }
606   if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
607     push @search, "quotation.quotationnum <= $1";
608   }
609
610 #  #charged
611 #  if ( $param->{charged} ) {
612 #    my @charged = ref($param->{charged})
613 #                    ? @{ $param->{charged} }
614 #                    : ($param->{charged});
615 #
616 #    push @search, map { s/^charged/cust_bill.charged/; $_; }
617 #                      @charged;
618 #  }
619
620   my $owed_sql = FS::cust_bill->owed_sql;
621
622   #days
623   push @search, "quotation._date < ". (time-86400*$param->{'days'})
624     if $param->{'days'};
625
626   #agent virtualization
627   my $curuser = $FS::CurrentUser::CurrentUser;
628   #false laziness w/search/quotation.html
629   push @search,' (    '. $curuser->agentnums_sql( table=>'prospect_main' ).
630                '   OR '. $curuser->agentnums_sql( table=>'cust_main' ).
631                ' )    ';
632
633   join(' AND ', @search );
634
635 }
636
637 =back
638
639 =head1 BUGS
640
641 =head1 SEE ALSO
642
643 L<FS::Record>, schema.html from the base documentation.
644
645 =cut
646
647 1;
648