email quotations, RT#22232, RT#20688
[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::cust_main;
11 use FS::cust_pkg;
12
13 =head1 NAME
14
15 FS::quotation - Object methods for quotation records
16
17 =head1 SYNOPSIS
18
19   use FS::quotation;
20
21   $record = new FS::quotation \%hash;
22   $record = new FS::quotation { 'column' => 'value' };
23
24   $error = $record->insert;
25
26   $error = $new_record->replace($old_record);
27
28   $error = $record->delete;
29
30   $error = $record->check;
31
32 =head1 DESCRIPTION
33
34 An FS::quotation object represents a quotation.  FS::quotation inherits from
35 FS::Record.  The following fields are currently supported:
36
37 =over 4
38
39 =item quotationnum
40
41 primary key
42
43 =item prospectnum
44
45 prospectnum
46
47 =item custnum
48
49 custnum
50
51 =item _date
52
53 _date
54
55 =item disabled
56
57 disabled
58
59 =item usernum
60
61 usernum
62
63
64 =back
65
66 =head1 METHODS
67
68 =over 4
69
70 =item new HASHREF
71
72 Creates a new quotation.  To add the quotation to the database, see L<"insert">.
73
74 Note that this stores the hash reference, not a distinct copy of the hash it
75 points to.  You can ask the object for a copy with the I<hash> method.
76
77 =cut
78
79 sub table { 'quotation'; }
80 sub notice_name { 'Quotation'; }
81 sub template_conf { 'quotation_'; }
82
83 =item insert
84
85 Adds this record to the database.  If there is an error, returns the error,
86 otherwise returns false.
87
88 =item delete
89
90 Delete this record from the database.
91
92 =item replace OLD_RECORD
93
94 Replaces the OLD_RECORD with this one in the database.  If there is an error,
95 returns the error, otherwise returns false.
96
97 =item check
98
99 Checks all fields to make sure this is a valid quotation.  If there is
100 an error, returns the error, otherwise returns false.  Called by the insert
101 and replace methods.
102
103 =cut
104
105 sub check {
106   my $self = shift;
107
108   my $error = 
109     $self->ut_numbern('quotationnum')
110     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum' )
111     || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum' )
112     || $self->ut_numbern('_date')
113     || $self->ut_enum('disabled', [ '', 'Y' ])
114     || $self->ut_numbern('usernum')
115   ;
116   return $error if $error;
117
118   $self->_date(time) unless $self->_date;
119
120   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
121
122   return 'prospectnum or custnum must be specified'
123     if ! $self->prospectnum
124     && ! $self->custnum;
125
126   $self->SUPER::check;
127 }
128
129 =item prospect_main
130
131 =item cust_main
132
133 =item cust_bill_pkg
134
135 =cut
136
137 sub cust_bill_pkg { #actually quotation_pkg objects
138   shift->quotation_pkg(@_);
139 }
140
141 =item total_setup
142
143 =cut
144
145 sub total_setup {
146   my $self = shift;
147   $self->_total('setup');
148 }
149
150 =item total_recur [ FREQ ]
151
152 =cut
153
154 sub total_recur {
155   my $self = shift;
156 #=item total_recur [ FREQ ]
157   #my $freq = @_ ? shift : '';
158   $self->_total('recur');
159 }
160
161 sub _total {
162   my( $self, $method ) = @_;
163
164   my $total = 0;
165   $total += $_->$method() for $self->cust_bill_pkg;
166   sprintf('%.2f', $total);
167
168 }
169
170 sub email {
171   my $self = shift;
172   my $opt = shift || {};
173   if ($opt and !ref($opt)) {
174     die ref($self). '->email called with positional parameters';
175   }
176
177   my $conf = $self->conf;
178
179   my $from = delete $opt->{from};
180
181   # this is where we set the From: address
182   $from ||= $conf->config('quotation_from', $self->cust_or_prospect->agentnum )
183          || $conf->config('invoice_from',   $self->cust_or_prospect->agentnum );
184
185   $self->SUPER::email( {
186     'from' => $from,
187     %$opt,
188   });
189
190 }
191
192 sub email_subject {
193   my $self = shift;
194
195   my $subject =
196     $self->conf->config('quotation_subject') #, $self->cust_main->agentnum)
197       || 'Quotation';
198
199   #my $cust_main = $self->cust_main;
200   #my $name = $cust_main->name;
201   #my $name_short = $cust_main->name_short;
202   #my $invoice_number = $self->invnum;
203   #my $invoice_date = $self->_date_pretty;
204
205   eval qq("$subject");
206 }
207
208 =item cust_or_prosect
209
210 =cut
211
212 sub cust_or_prospect {
213   my $self = shift;
214   $self->custnum ? $self->cust_main : $self->prospect_main;
215 }
216
217 =item cust_or_prospect_label_link P
218
219 HTML links to either the customer or prospect.
220
221 Returns a list consisting of two elements.  The first is a text label for the
222 link, and the second is the URL.
223
224 =cut
225
226 sub cust_or_prospect_label_link {
227   my( $self, $p ) = @_;
228
229   if ( my $custnum = $self->custnum ) {
230     my $display_custnum = $self->cust_main->display_custnum;
231     my $target = $FS::CurrentUser::CurrentUser->default_customer_view eq 'jumbo'
232                    ? '#quotations'
233                    : ';show=quotations';
234     (
235       emt("View this customer (#[_1])",$display_custnum) =>
236         "${p}view/cust_main.cgi?custnum=$custnum$target"
237     );
238   } elsif ( my $prospectnum = $self->prospectnum ) {
239     (
240       emt("View this prospect (#[_1])",$prospectnum) =>
241         "${p}view/prospect_main.html?$prospectnum"
242     );
243   } else { #die?
244     ( '', '' );
245   }
246
247 }
248
249 #prevent things from falsely showing up as taxes, at least until we support
250 # quoting tax amounts..
251 sub _items_tax {
252   return ();
253 }
254 sub _items_nontax {
255   shift->cust_bill_pkg;
256 }
257
258 sub _items_total {
259   my( $self, $total_items ) = @_;
260
261   if ( $self->total_setup > 0 ) {
262     push @$total_items, {
263       'total_item'   => $self->mt( $self->total_recur > 0 ? 'Total Setup' : 'Total' ),
264       'total_amount' => $self->total_setup,
265     };
266   }
267
268   #could/should add up the different recurring frequencies on lines of their own
269   # but this will cover the 95% cases for now
270   if ( $self->total_recur > 0 ) {
271     push @$total_items, {
272       'total_item'   => $self->mt('Total Recurring'),
273       'total_amount' => $self->total_recur,
274     };
275   }
276
277 }
278
279 =item enable_previous
280
281 =cut
282
283 sub enable_previous { 0 }
284
285 =item convert_cust_main
286
287 If this quotation already belongs to a customer, then returns that customer, as
288 an FS::cust_main object.
289
290 Otherwise, creates a new customer (FS::cust_main object and record, and
291 associated) based on this quotation's prospect, then orders this quotation's
292 packages as real packages for the customer.
293
294 If there is an error, returns an error message, otherwise, returns the
295 newly-created FS::cust_main object.
296
297 =cut
298
299 sub convert_cust_main {
300   my $self = shift;
301
302   my $cust_main = $self->cust_main;
303   return $cust_main if $cust_main; #already converted, don't again
304
305   my $oldAutoCommit = $FS::UID::AutoCommit;
306   local $FS::UID::AutoCommit = 0;
307   my $dbh = dbh;
308
309   $cust_main = $self->prospect_main->convert_cust_main;
310   unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
311     $dbh->rollback if $oldAutoCommit;
312     return $cust_main;
313   }
314
315   $self->prospectnum('');
316   $self->custnum( $cust_main->custnum );
317   my $error = $self->replace || $self->order;
318   if ( $error ) {
319     $dbh->rollback if $oldAutoCommit;
320     return $error;
321   }
322
323   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
324
325   $cust_main;
326
327 }
328
329 =item order
330
331 This method is for use with quotations which are already associated with a customer.
332
333 Orders this quotation's packages as real packages for the customer.
334
335 If there is an error, returns an error message, otherwise returns false.
336
337 =cut
338
339 sub order {
340   my $self = shift;
341
342   tie my %cust_pkg, 'Tie::RefHash',
343     map { FS::cust_pkg->new({ pkgpart  => $_->pkgpart,
344                               quantity => $_->quantity,
345                            })
346             => [] #services
347         }
348       $self->quotation_pkg ;
349
350   $self->cust_main->order_pkgs( \%cust_pkg );
351
352 }
353
354 =item disable
355
356 Disables this quotation (sets disabled to Y, which hides the quotation on
357 prospects and customers).
358
359 If there is an error, returns an error message, otherwise returns false.
360
361 =cut
362
363 sub disable {
364   my $self = shift;
365   $self->disabled('Y');
366   $self->replace();
367 }
368
369 =item enable
370
371 Enables this quotation.
372
373 If there is an error, returns an error message, otherwise returns false.
374
375 =cut
376
377 sub enable {
378   my $self = shift;
379   $self->disabled('');
380   $self->replace();
381 }
382
383 =back
384
385 =head1 CLASS METHODS
386
387 =over 4
388
389
390 =item search_sql_where HASHREF
391
392 Class method which returns an SQL WHERE fragment to search for parameters
393 specified in HASHREF.  Valid parameters are
394
395 =over 4
396
397 =item _date
398
399 List reference of start date, end date, as UNIX timestamps.
400
401 =item invnum_min
402
403 =item invnum_max
404
405 =item agentnum
406
407 =item charged
408
409 List reference of charged limits (exclusive).
410
411 =item owed
412
413 List reference of charged limits (exclusive).
414
415 =item open
416
417 flag, return open invoices only
418
419 =item net
420
421 flag, return net invoices only
422
423 =item days
424
425 =item newest_percust
426
427 =back
428
429 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
430
431 =cut
432
433 sub search_sql_where {
434   my($class, $param) = @_;
435   #if ( $DEBUG ) {
436   #  warn "$me search_sql_where called with params: \n".
437   #       join("\n", map { "  $_: ". $param->{$_} } keys %$param ). "\n";
438   #}
439
440   my @search = ();
441
442   #agentnum
443   if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
444     push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
445   }
446
447 #  #refnum
448 #  if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
449 #    push @search, "cust_main.refnum = $1";
450 #  }
451
452   #prospectnum
453   if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
454     push @search, "quotation.prospectnum = $1";
455   }
456
457   #custnum
458   if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
459     push @search, "cust_bill.custnum = $1";
460   }
461
462   #_date
463   if ( $param->{_date} ) {
464     my($beginning, $ending) = @{$param->{_date}};
465
466     push @search, "quotation._date >= $beginning",
467                   "quotation._date <  $ending";
468   }
469
470   #quotationnum
471   if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
472     push @search, "quotation.quotationnum >= $1";
473   }
474   if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
475     push @search, "quotation.quotationnum <= $1";
476   }
477
478 #  #charged
479 #  if ( $param->{charged} ) {
480 #    my @charged = ref($param->{charged})
481 #                    ? @{ $param->{charged} }
482 #                    : ($param->{charged});
483 #
484 #    push @search, map { s/^charged/cust_bill.charged/; $_; }
485 #                      @charged;
486 #  }
487
488   my $owed_sql = FS::cust_bill->owed_sql;
489
490   #days
491   push @search, "quotation._date < ". (time-86400*$param->{'days'})
492     if $param->{'days'};
493
494   #agent virtualization
495   my $curuser = $FS::CurrentUser::CurrentUser;
496   #false laziness w/search/quotation.html
497   push @search,' (    '. $curuser->agentnums_sql( table=>'prospect_main' ).
498                '   OR '. $curuser->agentnums_sql( table=>'cust_main' ).
499                ' )    ';
500
501   join(' AND ', @search );
502
503 }
504
505 =back
506
507 =head1 BUGS
508
509 =head1 SEE ALSO
510
511 L<FS::Record>, schema.html from the base documentation.
512
513 =cut
514
515 1;
516