convert prospects to customers via quotations, 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::cust_main;
10 use FS::cust_pkg;
11
12 =head1 NAME
13
14 FS::quotation - Object methods for quotation records
15
16 =head1 SYNOPSIS
17
18   use FS::quotation;
19
20   $record = new FS::quotation \%hash;
21   $record = new FS::quotation { 'column' => 'value' };
22
23   $error = $record->insert;
24
25   $error = $new_record->replace($old_record);
26
27   $error = $record->delete;
28
29   $error = $record->check;
30
31 =head1 DESCRIPTION
32
33 An FS::quotation object represents a quotation.  FS::quotation inherits from
34 FS::Record.  The following fields are currently supported:
35
36 =over 4
37
38 =item quotationnum
39
40 primary key
41
42 =item prospectnum
43
44 prospectnum
45
46 =item custnum
47
48 custnum
49
50 =item _date
51
52 _date
53
54 =item disabled
55
56 disabled
57
58 =item usernum
59
60 usernum
61
62
63 =back
64
65 =head1 METHODS
66
67 =over 4
68
69 =item new HASHREF
70
71 Creates a new quotation.  To add the quotation to the database, see L<"insert">.
72
73 Note that this stores the hash reference, not a distinct copy of the hash it
74 points to.  You can ask the object for a copy with the I<hash> method.
75
76 =cut
77
78 sub table { 'quotation'; }
79 sub notice_name { 'Quotation'; }
80 sub template_conf { 'quotation_'; }
81
82 =item insert
83
84 Adds this record to the database.  If there is an error, returns the error,
85 otherwise returns false.
86
87 =item delete
88
89 Delete this record from the database.
90
91 =item replace OLD_RECORD
92
93 Replaces the OLD_RECORD with this one in the database.  If there is an error,
94 returns the error, otherwise returns false.
95
96 =item check
97
98 Checks all fields to make sure this is a valid quotation.  If there is
99 an error, returns the error, otherwise returns false.  Called by the insert
100 and replace methods.
101
102 =cut
103
104 sub check {
105   my $self = shift;
106
107   my $error = 
108     $self->ut_numbern('quotationnum')
109     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum' )
110     || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum' )
111     || $self->ut_numbern('_date')
112     || $self->ut_enum('disabled', [ '', 'Y' ])
113     || $self->ut_numbern('usernum')
114   ;
115   return $error if $error;
116
117   $self->_date(time) unless $self->_date;
118
119   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
120
121   $self->SUPER::check;
122 }
123
124 =item prospect_main
125
126 =item cust_main
127
128 =item cust_bill_pkg
129
130 =cut
131
132 sub cust_bill_pkg { #actually quotation_pkg objects
133   shift->quotation_pkg(@_);
134 }
135
136 =item total_setup
137
138 =cut
139
140 sub total_setup {
141   my $self = shift;
142   $self->_total('setup');
143 }
144
145 =item total_recur [ FREQ ]
146
147 =cut
148
149 sub total_recur {
150   my $self = shift;
151 #=item total_recur [ FREQ ]
152   #my $freq = @_ ? shift : '';
153   $self->_total('recur');
154 }
155
156 sub _total {
157   my( $self, $method ) = @_;
158
159   my $total = 0;
160   $total += $_->$method() for $self->cust_bill_pkg;
161   sprintf('%.2f', $total);
162
163 }
164
165 #prevent things from falsely showing up as taxes, at least until we support
166 # quoting tax amounts..
167 sub _items_tax {
168   return ();
169 }
170 sub _items_nontax {
171   shift->cust_bill_pkg;
172 }
173
174 sub _items_total {
175   my( $self, $total_items ) = @_;
176
177   if ( $self->total_setup > 0 ) {
178     push @$total_items, {
179       'total_item'   => $self->mt( $self->total_recur > 0 ? 'Total Setup' : 'Total' ),
180       'total_amount' => $self->total_setup,
181     };
182   }
183
184   #could/should add up the different recurring frequencies on lines of their own
185   # but this will cover the 95% cases for now
186   if ( $self->total_recur > 0 ) {
187     push @$total_items, {
188       'total_item'   => $self->mt('Total Recurring'),
189       'total_amount' => $self->total_recur,
190     };
191   }
192
193 }
194
195 =item enable_previous
196
197 =cut
198
199 sub enable_previous { 0 }
200
201 =item convert_cust_main
202
203 If this quotation already belongs to a customer, then returns that customer, as
204 an FS::cust_main object.
205
206 Otherwise, creates a new customer (FS::cust_main object and record, and
207 associated) based on this quotation's prospect, then orders this quotation's
208 packages as real packages for the customer.
209
210 If there is an error, returns an error message, otherwise, returns the
211 newly-created FS::cust_main object.
212
213 =cut
214
215 sub convert_cust_main {
216   my $self = shift;
217
218   my $cust_main = $self->cust_main;
219   return $cust_main if $cust_main; #already converted, don't again
220
221   my $oldAutoCommit = $FS::UID::AutoCommit;
222   local $FS::UID::AutoCommit = 0;
223   my $dbh = dbh;
224
225   $cust_main = $self->prospect_main->convert_cust_main;
226   unless ( ref($cust_main) ) { # eq 'FS::cust_main' ) {
227     $dbh->rollback if $oldAutoCommit;
228     return $cust_main;
229   }
230
231   $self->prospectnum('');
232   $self->custnum( $cust_main->custnum );
233   my $error = $self->replace || $self->order;
234   if ( $error ) {
235     $dbh->rollback if $oldAutoCommit;
236     return $error;
237   }
238
239   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
240
241   $cust_main;
242
243 }
244
245 =item order
246
247 This method is for use with quotations which are already associated with a customer.
248
249 Orders this quotation's packages as real packages for the customer.
250
251 If there is an error, returns an error message, otherwise returns false.
252
253 =cut
254
255 sub order {
256   my $self = shift;
257
258   tie my %cust_pkg, 'Tie::RefHash',
259     map { FS::cust_pkg->new({ pkgpart  => $_->pkgpart,
260                               quantity => $_->quantity,
261                            })
262             => [] #services
263         }
264       $self->quotation_pkg ;
265
266   $self->cust_main->order_pkgs( \%cust_pkg );
267
268 }
269
270 =back
271
272 =head1 CLASS METHODS
273
274 =over 4
275
276
277 =item search_sql_where HASHREF
278
279 Class method which returns an SQL WHERE fragment to search for parameters
280 specified in HASHREF.  Valid parameters are
281
282 =over 4
283
284 =item _date
285
286 List reference of start date, end date, as UNIX timestamps.
287
288 =item invnum_min
289
290 =item invnum_max
291
292 =item agentnum
293
294 =item charged
295
296 List reference of charged limits (exclusive).
297
298 =item owed
299
300 List reference of charged limits (exclusive).
301
302 =item open
303
304 flag, return open invoices only
305
306 =item net
307
308 flag, return net invoices only
309
310 =item days
311
312 =item newest_percust
313
314 =back
315
316 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
317
318 =cut
319
320 sub search_sql_where {
321   my($class, $param) = @_;
322   #if ( $DEBUG ) {
323   #  warn "$me search_sql_where called with params: \n".
324   #       join("\n", map { "  $_: ". $param->{$_} } keys %$param ). "\n";
325   #}
326
327   my @search = ();
328
329   #agentnum
330   if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
331     push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
332   }
333
334 #  #refnum
335 #  if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
336 #    push @search, "cust_main.refnum = $1";
337 #  }
338
339   #prospectnum
340   if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
341     push @search, "quotation.prospectnum = $1";
342   }
343
344   #custnum
345   if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
346     push @search, "cust_bill.custnum = $1";
347   }
348
349   #_date
350   if ( $param->{_date} ) {
351     my($beginning, $ending) = @{$param->{_date}};
352
353     push @search, "quotation._date >= $beginning",
354                   "quotation._date <  $ending";
355   }
356
357   #quotationnum
358   if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
359     push @search, "quotation.quotationnum >= $1";
360   }
361   if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
362     push @search, "quotation.quotationnum <= $1";
363   }
364
365 #  #charged
366 #  if ( $param->{charged} ) {
367 #    my @charged = ref($param->{charged})
368 #                    ? @{ $param->{charged} }
369 #                    : ($param->{charged});
370 #
371 #    push @search, map { s/^charged/cust_bill.charged/; $_; }
372 #                      @charged;
373 #  }
374
375   my $owed_sql = FS::cust_bill->owed_sql;
376
377   #days
378   push @search, "quotation._date < ". (time-86400*$param->{'days'})
379     if $param->{'days'};
380
381   #agent virtualization
382   my $curuser = $FS::CurrentUser::CurrentUser;
383   #false laziness w/search/quotation.html
384   push @search,' (    '. $curuser->agentnums_sql( table=>'prospect_main' ).
385                '   OR '. $curuser->agentnums_sql( table=>'cust_main' ).
386                ' )    ';
387
388   join(' AND ', @search );
389
390 }
391
392 =back
393
394 =head1 BUGS
395
396 =head1 SEE ALSO
397
398 L<FS::Record>, schema.html from the base documentation.
399
400 =cut
401
402 1;
403