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