58ac898c50bf6d8f971869aef15f5ec63f037ff2
[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::UID qw( dbh );
7 use FS::Maketext qw( emt );
8 use FS::Record qw( qsearch qsearchs );
9 use FS::CurrentUser;
10 use FS::cust_main;
11 use FS::cust_pkg;
12 use FS::prospect_main;
13 use FS::quotation_pkg;
14
15 =head1 NAME
16
17 FS::quotation - Object methods for quotation records
18
19 =head1 SYNOPSIS
20
21   use FS::quotation;
22
23   $record = new FS::quotation \%hash;
24   $record = new FS::quotation { 'column' => 'value' };
25
26   $error = $record->insert;
27
28   $error = $new_record->replace($old_record);
29
30   $error = $record->delete;
31
32   $error = $record->check;
33
34 =head1 DESCRIPTION
35
36 An FS::quotation object represents a quotation.  FS::quotation inherits from
37 FS::Record.  The following fields are currently supported:
38
39 =over 4
40
41 =item quotationnum
42
43 primary key
44
45 =item prospectnum
46
47 prospectnum
48
49 =item custnum
50
51 custnum
52
53 =item _date
54
55 _date
56
57 =item disabled
58
59 disabled
60
61 =item usernum
62
63 usernum
64
65
66 =back
67
68 =head1 METHODS
69
70 =over 4
71
72 =item new HASHREF
73
74 Creates a new quotation.  To add the quotation to the database, see L<"insert">.
75
76 Note that this stores the hash reference, not a distinct copy of the hash it
77 points to.  You can ask the object for a copy with the I<hash> method.
78
79 =cut
80
81 sub table { 'quotation'; }
82 sub notice_name { 'Quotation'; }
83 sub template_conf { 'quotation_'; }
84
85 =item insert
86
87 Adds this record to the database.  If there is an error, returns the error,
88 otherwise returns false.
89
90 =item delete
91
92 Delete this record from the database.
93
94 =item replace OLD_RECORD
95
96 Replaces the OLD_RECORD with this one in the database.  If there is an error,
97 returns the error, otherwise returns false.
98
99 =item check
100
101 Checks all fields to make sure this is a valid quotation.  If there is
102 an error, returns the error, otherwise returns false.  Called by the insert
103 and replace methods.
104
105 =cut
106
107 sub check {
108   my $self = shift;
109
110   my $error = 
111     $self->ut_numbern('quotationnum')
112     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum' )
113     || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum' )
114     || $self->ut_numbern('_date')
115     || $self->ut_enum('disabled', [ '', 'Y' ])
116     || $self->ut_numbern('usernum')
117   ;
118   return $error if $error;
119
120   $self->_date(time) unless $self->_date;
121
122   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
123
124   return 'prospectnum or custnum must be specified'
125     if ! $self->prospectnum
126     && ! $self->custnum;
127
128   $self->SUPER::check;
129 }
130
131 =item prospect_main
132
133 =cut
134
135 sub prospect_main {
136   my $self = shift;
137   qsearchs('prospect_main', { 'prospectnum' => $self->prospectnum } );
138 }
139
140 =item cust_main
141
142 =cut
143
144 sub cust_main {
145   my $self = shift;
146   qsearchs('cust_main', { 'custnum' => $self->custnum } );
147 }
148
149 =item cust_bill_pkg
150
151 =cut
152
153 sub cust_bill_pkg { #actually quotation_pkg objects
154   my $self = shift;
155   qsearch('quotation_pkg', { quotationnum=>$self->quotationnum });
156 }
157
158 =item total_setup
159
160 =cut
161
162 sub total_setup {
163   my $self = shift;
164   $self->_total('setup');
165 }
166
167 =item total_recur [ FREQ ]
168
169 =cut
170
171 sub total_recur {
172   my $self = shift;
173 #=item total_recur [ FREQ ]
174   #my $freq = @_ ? shift : '';
175   $self->_total('recur');
176 }
177
178 sub _total {
179   my( $self, $method ) = @_;
180
181   my $total = 0;
182   $total += $_->$method() for $self->cust_bill_pkg;
183   sprintf('%.2f', $total);
184
185 }
186
187 sub email {
188   my $self = shift;
189   my $opt = shift || {};
190   if ($opt and !ref($opt)) {
191     die ref($self). '->email called with positional parameters';
192   }
193
194   my $conf = $self->conf;
195
196   my $from = delete $opt->{from};
197
198   # this is where we set the From: address
199   $from ||= $conf->config('quotation_from', $self->cust_or_prospect->agentnum )
200          || $conf->config('invoice_from',   $self->cust_or_prospect->agentnum );
201
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 %cust_pkg, 'Tie::RefHash',
360     map { FS::cust_pkg->new({ pkgpart  => $_->pkgpart,
361                               quantity => $_->quantity,
362                            })
363             => [] #services
364         }
365       $self->quotation_pkg ;
366
367   $self->cust_main->order_pkgs( \%cust_pkg );
368
369 }
370
371 =item quotation_pkg
372
373 =cut
374
375 sub quotation_pkg {
376   my $self = shift;
377   qsearch('quotation_pkg', { 'quotationnum' => $self->quotationnum } );
378 }
379
380 =item disable
381
382 Disables this quotation (sets disabled to Y, which hides the quotation on
383 prospects and customers).
384
385 If there is an error, returns an error message, otherwise returns false.
386
387 =cut
388
389 sub disable {
390   my $self = shift;
391   $self->disabled('Y');
392   $self->replace();
393 }
394
395 =item enable
396
397 Enables this quotation.
398
399 If there is an error, returns an error message, otherwise returns false.
400
401 =cut
402
403 sub enable {
404   my $self = shift;
405   $self->disabled('');
406   $self->replace();
407 }
408
409 =back
410
411 =head1 CLASS METHODS
412
413 =over 4
414
415
416 =item search_sql_where HASHREF
417
418 Class method which returns an SQL WHERE fragment to search for parameters
419 specified in HASHREF.  Valid parameters are
420
421 =over 4
422
423 =item _date
424
425 List reference of start date, end date, as UNIX timestamps.
426
427 =item invnum_min
428
429 =item invnum_max
430
431 =item agentnum
432
433 =item charged
434
435 List reference of charged limits (exclusive).
436
437 =item owed
438
439 List reference of charged limits (exclusive).
440
441 =item open
442
443 flag, return open invoices only
444
445 =item net
446
447 flag, return net invoices only
448
449 =item days
450
451 =item newest_percust
452
453 =back
454
455 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
456
457 =cut
458
459 sub search_sql_where {
460   my($class, $param) = @_;
461   #if ( $DEBUG ) {
462   #  warn "$me search_sql_where called with params: \n".
463   #       join("\n", map { "  $_: ". $param->{$_} } keys %$param ). "\n";
464   #}
465
466   my @search = ();
467
468   #agentnum
469   if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
470     push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
471   }
472
473 #  #refnum
474 #  if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
475 #    push @search, "cust_main.refnum = $1";
476 #  }
477
478   #prospectnum
479   if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
480     push @search, "quotation.prospectnum = $1";
481   }
482
483   #custnum
484   if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
485     push @search, "cust_bill.custnum = $1";
486   }
487
488   #_date
489   if ( $param->{_date} ) {
490     my($beginning, $ending) = @{$param->{_date}};
491
492     push @search, "quotation._date >= $beginning",
493                   "quotation._date <  $ending";
494   }
495
496   #quotationnum
497   if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
498     push @search, "quotation.quotationnum >= $1";
499   }
500   if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
501     push @search, "quotation.quotationnum <= $1";
502   }
503
504 #  #charged
505 #  if ( $param->{charged} ) {
506 #    my @charged = ref($param->{charged})
507 #                    ? @{ $param->{charged} }
508 #                    : ($param->{charged});
509 #
510 #    push @search, map { s/^charged/cust_bill.charged/; $_; }
511 #                      @charged;
512 #  }
513
514   my $owed_sql = FS::cust_bill->owed_sql;
515
516   #days
517   push @search, "quotation._date < ". (time-86400*$param->{'days'})
518     if $param->{'days'};
519
520   #agent virtualization
521   my $curuser = $FS::CurrentUser::CurrentUser;
522   #false laziness w/search/quotation.html
523   push @search,' (    '. $curuser->agentnums_sql( table=>'prospect_main' ).
524                '   OR '. $curuser->agentnums_sql( table=>'cust_main' ).
525                ' )    ';
526
527   join(' AND ', @search );
528
529 }
530
531 =back
532
533 =head1 BUGS
534
535 =head1 SEE ALSO
536
537 L<FS::Record>, schema.html from the base documentation.
538
539 =cut
540
541 1;
542