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