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