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