bdad511fa696950625f0cba6efcc3f4d1c5ae0e9
[freeside.git] / FS / FS / cust_main_Mixin.pm
1 package FS::cust_main_Mixin;
2
3 use strict;
4 use vars qw( $DEBUG $me );
5 use Carp qw( confess carp cluck );
6 use FS::UID qw(dbh);
7 use FS::cust_main;
8 use FS::Record qw( qsearch qsearchs );
9 use FS::Misc qw( send_email generate_email );
10 use HTML::Entities;
11
12 $DEBUG = 0;
13 $me = '[FS::cust_main_Mixin]';
14
15 =head1 NAME
16
17 FS::cust_main_Mixin - Mixin class for records that contain fields from cust_main
18
19 =head1 SYNOPSIS
20
21 package FS::some_table;
22 use vars qw(@ISA);
23 @ISA = qw( FS::cust_main_Mixin FS::Record );
24
25 =head1 DESCRIPTION
26
27 This is a mixin class for records that contain fields from the cust_main table,
28 for example, from a JOINed search.  See httemplate/search/ for examples.
29
30 =head1 METHODS
31
32 =over 4
33
34 =cut
35
36 sub cust_unlinked_msg { '(unlinked)'; }
37 sub cust_linked { $_[0]->custnum; }
38
39 sub cust_main { 
40   my $self = shift;
41   cluck ref($self). '->cust_main called' if $DEBUG;
42   $self->cust_linked ? qsearchs('cust_main', {custnum => $self->custnum}) : '';
43 }
44
45 =item display_custnum
46
47 Given an object that contains fields from cust_main (say, from a JOINed
48 search; see httemplate/search/ for examples), returns the equivalent of the
49 FS::cust_main I<name> method, or "(unlinked)" if this object is not linked to
50 a customer.
51
52 =cut
53
54 sub display_custnum {
55   my $self = shift;
56   $self->cust_linked
57     ? FS::cust_main::display_custnum($self)
58     : $self->cust_unlinked_msg;
59 }
60
61 =item name
62
63 Given an object that contains fields from cust_main (say, from a JOINed
64 search; see httemplate/search/ for examples), returns the equivalent of the
65 FS::cust_main I<name> method, or "(unlinked)" if this object is not linked to
66 a customer.
67
68 =cut
69
70 sub name {
71   my $self = shift;
72   $self->cust_linked
73     ? FS::cust_main::name($self)
74     : $self->cust_unlinked_msg;
75 }
76
77 =item ship_name
78
79 Given an object that contains fields from cust_main (say, from a JOINed
80 search; see httemplate/search/ for examples), returns the equivalent of the
81 FS::cust_main I<ship_name> method, or "(unlinked)" if this object is not
82 linked to a customer.
83
84 =cut
85
86 sub ship_name {
87   my $self = shift;
88   $self->cust_linked
89     ? FS::cust_main::ship_name($self)
90     : $self->cust_unlinked_msg;
91 }
92
93 =item contact
94
95 Given an object that contains fields from cust_main (say, from a JOINed
96 search; see httemplate/search/ for examples), returns the equivalent of the
97 FS::cust_main I<contact> method, or "(unlinked)" if this object is not linked
98 to a customer.
99
100 =cut
101
102 sub contact {
103   my $self = shift;
104   $self->cust_linked
105     ? FS::cust_main::contact($self)
106     : $self->cust_unlinked_msg;
107 }
108
109 =item ship_contact
110
111 Given an object that contains fields from cust_main (say, from a JOINed
112 search; see httemplate/search/ for examples), returns the equivalent of the
113 FS::cust_main I<ship_contact> method, or "(unlinked)" if this object is not
114 linked to a customer.
115
116 =cut
117
118 sub ship_contact {
119   my $self = shift;
120   $self->cust_linked
121     ? FS::cust_main::ship_contact($self)
122     : $self->cust_unlinked_msg;
123 }
124
125 =item country_full
126
127 Given an object that contains fields from cust_main (say, from a JOINed
128 search; see httemplate/search/ for examples), returns the equivalent of the
129 FS::cust_main I<country_full> method, or "(unlinked)" if this object is not
130 linked to a customer.
131
132 =cut
133
134 sub country_full {
135   my $self = shift;
136   if ( $self->locationnum ) {  # cust_pkg has this
137     my $location = FS::cust_location->by_key($self->locationnum);
138     $location ? $location->country_full : '';
139   } elsif ( $self->cust_linked ) {
140     $self->cust_main->bill_country_full;
141   }
142 }
143
144 =item invoicing_list_emailonly
145
146 Given an object that contains fields from cust_main (say, from a JOINed
147 search; see httemplate/search/ for examples), returns the equivalent of the
148 FS::cust_main I<invoicing_list_emailonly> method, or "(unlinked)" if this
149 object is not linked to a customer.
150
151 =cut
152
153 sub invoicing_list_emailonly {
154   my $self = shift;
155   warn "invoicing_list_email only called on $self, ".
156        "custnum ". $self->custnum. "\n"
157     if $DEBUG;
158   $self->cust_linked
159     ? FS::cust_main::invoicing_list_emailonly($self)
160     : $self->cust_unlinked_msg;
161 }
162
163 =item invoicing_list_emailonly_scalar
164
165 Given an object that contains fields from cust_main (say, from a JOINed
166 search; see httemplate/search/ for examples), returns the equivalent of the
167 FS::cust_main I<invoicing_list_emailonly_scalar> method, or "(unlinked)" if
168 this object is not linked to a customer.
169
170 =cut
171
172 sub invoicing_list_emailonly_scalar {
173   my $self = shift;
174   warn "invoicing_list_emailonly called on $self, ".
175        "custnum ". $self->custnum. "\n"
176     if $DEBUG;
177   $self->cust_linked
178     ? FS::cust_main::invoicing_list_emailonly_scalar($self)
179     : $self->cust_unlinked_msg;
180 }
181
182 =item invoicing_list
183
184 Given an object that contains fields from cust_main (say, from a JOINed
185 search; see httemplate/search/ for examples), returns the equivalent of the
186 FS::cust_main I<invoicing_list> method, or "(unlinked)" if this object is not
187 linked to a customer.
188
189 Note: this method is read-only.
190
191 =cut
192
193 #read-only
194 sub invoicing_list {
195   my $self = shift;
196   $self->cust_linked
197     ? FS::cust_main::invoicing_list($self)
198     : ();
199 }
200
201 =item status
202
203 Given an object that contains fields from cust_main (say, from a JOINed
204 search; see httemplate/search/ for examples), returns the equivalent of the
205 FS::cust_main I<status> method, or "(unlinked)" if this object is not linked to
206 a customer.
207
208 =cut
209
210 sub cust_status {
211   my $self = shift;
212   return $self->cust_unlinked_msg unless $self->cust_linked;
213
214   #FS::cust_main::status($self)
215   #false laziness w/actual cust_main::status
216   # (make sure FS::cust_main methods are called)
217   for my $status (qw( prospect active inactive suspended cancelled )) {
218     my $method = $status.'_sql';
219     my $sql = FS::cust_main->$method();;
220     my $numnum = ( $sql =~ s/cust_main\.custnum/?/g );
221     my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr;
222     $sth->execute( ($self->custnum) x $numnum )
223       or die "Error executing 'SELECT $sql': ". $sth->errstr;
224     return $status if $sth->fetchrow_arrayref->[0];
225   }
226 }
227
228 =item ucfirst_cust_status
229
230 Given an object that contains fields from cust_main (say, from a JOINed
231 search; see httemplate/search/ for examples), returns the equivalent of the
232 FS::cust_main I<ucfirst_status> method, or "(unlinked)" if this object is not
233 linked to a customer.
234
235 =cut
236
237 sub ucfirst_cust_status {
238   carp "ucfirst_cust_status deprecated, use cust_status_label";
239   local($FS::cust_main::ucfirst_nowarn) = 1;
240   my $self = shift;
241   $self->cust_linked
242     ? ucfirst( $self->cust_status(@_) ) 
243     : $self->cust_unlinked_msg;
244 }
245
246 =item cust_status_label
247
248 =cut
249
250 sub cust_status_label {
251   my $self = shift;
252
253   $self->cust_linked
254     ? FS::cust_main::cust_status_label($self)
255     : $self->cust_unlinked_msg;
256 }
257
258 =item cust_statuscolor
259
260 Given an object that contains fields from cust_main (say, from a JOINed
261 search; see httemplate/search/ for examples), returns the equivalent of the
262 FS::cust_main I<statuscol> method, or "000000" if this object is not linked to
263 a customer.
264
265 =cut
266
267 sub cust_statuscolor {
268   my $self = shift;
269
270   $self->cust_linked
271     ? FS::cust_main::cust_statuscolor($self)
272     : '000000';
273 }
274
275 =item prospect_sql
276
277 =item active_sql
278
279 =item inactive_sql
280
281 =item suspended_sql
282
283 =item cancelled_sql
284
285 Class methods that return SQL framents, equivalent to the corresponding
286 FS::cust_main method.
287
288 =cut
289
290 #      my \$self = shift;
291 #      \$self->cust_linked
292 #        ? FS::cust_main::${sub}_sql(\$self)
293 #        : '0';
294
295 foreach my $sub (qw( prospect active inactive suspended cancelled )) {
296   eval "
297     sub ${sub}_sql {
298       confess 'cust_main_Mixin ${sub}_sql called with object' if ref(\$_[0]);
299       'cust_main.custnum IS NOT NULL AND '. FS::cust_main->${sub}_sql();
300     }
301   ";
302   die $@ if $@;
303 }
304
305 =item cust_search_sql
306
307 Returns a list of SQL WHERE fragments to search for parameters specified
308 in HASHREF.  Valid parameters are:
309
310 =over 4
311
312 =item agentnum
313
314 =item status
315
316 =item payby
317
318 =back
319
320 =cut
321
322 sub cust_search_sql {
323   my($class, $param) = @_;
324
325   if ( $DEBUG ) {
326     warn "$me cust_search_sql called with params: \n".
327          join("\n", map { "  $_: ". $param->{$_} } keys %$param ). "\n";
328   }
329
330   my @search = ();
331
332   if ( $param->{'agentnum'} && $param->{'agentnum'} =~ /^(\d+)$/ ) {
333     push @search, "cust_main.agentnum = $1";
334   }
335
336   #status (prospect active inactive suspended cancelled)
337   if ( grep { $param->{'status'} eq $_ } FS::cust_main->statuses() ) {
338     my $method = $param->{'status'}. '_sql';
339     push @search, $class->$method();
340   }
341
342   #payby
343   my @payby = ref($param->{'payby'})
344                 ? @{ $param->{'payby'} }
345                 : split(',', $param->{'payby'});
346   @payby = grep /^([A-Z]{4})$/, @payby;
347   if ( @payby ) {
348     push @search, 'cust_main.payby IN ('. join(',', map "'$_'", @payby). ')';
349   }
350
351   #here is the agent virtualization
352   push @search,
353     $FS::CurrentUser::CurrentUser->agentnums_sql( 'table' => 'cust_main' );
354   
355   return @search;
356
357 }
358
359 =item email_search_result HASHREF
360
361 Emails a notice to the specified customers.  Customers without 
362 invoice email destinations will be skipped.
363
364 Parameters: 
365
366 =over 4
367
368 =item job
369
370 Queue job for status updates.  Required.
371
372 =item search
373
374 Hashref of params to the L<search()> method.  Required.
375
376 =item msgnum
377
378 Message template number (see L<FS::msg_template>).  Overrides all 
379 of the following options.
380
381 =item from
382
383 From: address
384
385 =item subject
386
387 Email Subject:
388
389 =item html_body
390
391 HTML body
392
393 =item text_body
394
395 Text body
396
397 =back
398
399 Returns an error message, or false for success.
400
401 If any messages fail to send, they will be queued as individual 
402 jobs which can be manually retried.  If the first ten messages 
403 in the job fail, the entire job will abort and return an error.
404
405 =cut
406
407 use Storable qw(thaw);
408 use MIME::Base64;
409 use Data::Dumper qw(Dumper);
410 use Digest::SHA qw(sha1); # for duplicate checking
411
412 sub email_search_result {
413   my($class, $param) = @_;
414
415   my $msgnum = $param->{msgnum};
416   my $from = delete $param->{from};
417   my $subject = delete $param->{subject};
418   my $html_body = delete $param->{html_body};
419   my $text_body = delete $param->{text_body};
420   my $error = '';
421
422   my $job = delete $param->{'job'}
423     or die "email_search_result must run from the job queue.\n";
424   
425   my $msg_template;
426   if ( $msgnum ) {
427     $msg_template = qsearchs('msg_template', { msgnum => $msgnum } )
428       or die "msgnum $msgnum not found\n";
429   }
430
431   my $sql_query = $class->search($param->{'search'});
432   $sql_query->{'select'} = $sql_query->{'table'} . '.*';
433
434   my $count_query   = delete($sql_query->{'count_query'});
435   my $count_sth = dbh->prepare($count_query)
436     or die "Error preparing $count_query: ". dbh->errstr;
437   $count_sth->execute
438     or die "Error executing $count_query: ". $count_sth->errstr;
439   my $count_arrayref = $count_sth->fetchrow_arrayref;
440   my $num_cust = $count_arrayref->[0];
441
442   my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo
443   my @retry_jobs = ();
444   my $dups = 0;
445   my $success = 0;
446   my %sent_to = ();
447
448   #eventually order+limit magic to reduce memory use?
449   foreach my $obj ( qsearch($sql_query) ) {
450
451     #progressbar first, so that the count is right
452     $num++;
453     if ( time - $min_sec > $last ) {
454       my $error = $job->update_statustext(
455         int( 100 * $num / $num_cust )
456       );
457       die $error if $error;
458       $last = time;
459     }
460
461     my $cust_main = $obj->cust_main;
462     tie my %message, 'Tie::IxHash';
463     if ( !$cust_main ) { 
464       next; # unlinked object; nothing else we can do
465     }
466
467     if ( $msg_template ) {
468       # Now supports other context objects.
469       %message = $msg_template->prepare(
470         'cust_main' => $cust_main,
471         'object'    => $obj,
472       );
473     }
474     else {
475       my @to = $cust_main->invoicing_list_emailonly;
476       next if !@to;
477
478       %message = (
479         'from'      => $from,
480         'to'        => \@to,
481         'subject'   => $subject,
482         'html_body' => $html_body,
483         'text_body' => $text_body,
484         'custnum'   => $cust_main->custnum,
485       );
486     } #if $msg_template
487
488     # For non-cust_main searches, we avoid duplicates based on message
489     # body text.  
490     my $unique = $cust_main->custnum;
491     $unique .= sha1($message{'text_body'}) if $class ne 'FS::cust_main';
492     if( $sent_to{$unique} ) {
493       # avoid duplicates
494       $dups++;
495       next;
496     }
497
498     $sent_to{$unique} = 1;
499     
500     $error = send_email( generate_email( %message ) );
501
502     if($error) {
503       # queue the sending of this message so that the user can see what we
504       # tried to do, and retry if desired
505       my $queue = new FS::queue {
506         'job'        => 'FS::Misc::process_send_email',
507         'custnum'    => $cust_main->custnum,
508         'status'     => 'failed',
509         'statustext' => $error,
510       };
511       $queue->insert(%message);
512       push @retry_jobs, $queue;
513     }
514     else {
515       $success++;
516     }
517
518     if($success == 0 and
519         (scalar(@retry_jobs) > 10 or $num == $num_cust)
520       ) {
521       # 10 is arbitrary, but if we have enough failures, that's
522       # probably a configuration or network problem, and we
523       # abort the batch and run away screaming.
524       # We NEVER do this if anything was successfully sent.
525       $_->delete foreach (@retry_jobs);
526       return "multiple failures: '$error'\n";
527     }
528   } # foreach $obj
529
530   if(@retry_jobs) {
531     # fail the job, but with a status message that makes it clear
532     # something was sent.
533     return "Sent $success, skipped $dups duplicate(s), failed ".scalar(@retry_jobs).". Failed attempts placed in job queue.\n";
534   }
535
536   return '';
537 }
538
539 sub process_email_search_result {
540   my $job = shift;
541   #warn "$me process_re_X $method for job $job\n" if $DEBUG;
542
543   my $param = shift;
544   warn Dumper($param) if $DEBUG;
545
546   $param->{'job'} = $job;
547
548   $param->{'search'} = thaw(decode_base64($param->{'search'}))
549     or die "process_email_search_result requires search params.\n";
550
551 #  $param->{'payby'} = [ split(/\0/, $param->{'payby'}) ]
552 #    unless ref($param->{'payby'});
553
554   my $table = $param->{'table'} 
555     or die "process_email_search_result requires table.\n";
556
557   eval "use FS::$table;";
558   die "error loading FS::$table: $@\n" if $@;
559
560   my $error = "FS::$table"->email_search_result( $param );
561   dbh->commit; # save failed jobs before rethrowing the error
562   die $error if $error;
563
564 }
565
566 =item conf
567
568 Returns a configuration handle (L<FS::Conf>) set to the customer's locale, 
569 if they have one.  If not, returns an FS::Conf with no locale.
570
571 =cut
572
573 sub conf {
574   my $self = shift;
575   return $self->{_conf} if (ref $self and $self->{_conf});
576   my $cust_main = $self->cust_main;
577   my $conf = new FS::Conf { 
578     'locale' => ($cust_main ? $cust_main->locale : '')
579   };
580   $self->{_conf} = $conf if ref $self;
581   return $conf;
582 }
583
584 =item mt TEXT [, ARGS ]
585
586 Localizes a text string (see L<Locale::Maketext>) for the customer's locale,
587 if they have one.
588
589 =cut
590
591 sub mt {
592   my $self = shift;
593   return $self->{_lh}->maketext(@_) if (ref $self and $self->{_lh});
594   my $cust_main = $self->cust_main;
595   my $locale = $cust_main ? $cust_main->locale : '';
596   my $lh = FS::L10N->get_handle($locale);
597   $self->{_lh} = $lh if ref $self;
598   return $lh->maketext(@_);
599 }
600
601 =item time2str_local FORMAT, TIME[, ESCAPE]
602
603 Localizes a date (see L<Date::Language>) for the customer's locale.
604
605 FORMAT can be a L<Date::Format> string, or one of these special words:
606
607 - "short": the value of the "date_format" config setting for the customer's 
608   locale, defaulting to "%x".
609 - "rdate": the same as "short" except that the default has a four-digit year.
610 - "long": the value of the "date_format_long" config setting for the 
611   customer's locale, defaulting to "%b %o, %Y".
612
613 ESCAPE, if specified, is one of "latex" or "html", and will escape non-ASCII
614 characters and convert spaces to nonbreaking spaces.
615
616 =cut
617
618 sub time2str_local {
619   # renamed so that we don't have to change every single reference to 
620   # time2str everywhere
621   my $self = shift;
622   my ($format, $time, $escape) = @_;
623   return '' unless $time > 0; # work around time2str's traditional stupidity
624
625   $self->{_date_format} ||= {};
626   if (!exists($self->{_dh})) {
627     my $cust_main = $self->cust_main;
628     my $locale = $cust_main->locale  if $cust_main;
629     $locale ||= 'en_US';
630     my %info = FS::Locales->locale_info($locale);
631     my $dh = eval { Date::Language->new($info{'name'}) } ||
632              Date::Language->new(); # fall back to English
633     $self->{_dh} = $dh;
634   }
635
636   if ($format eq 'short') {
637     $format = $self->{_date_format}->{short}
638             ||= $self->conf->config('date_format') || '%x';
639   } elsif ($format eq 'rdate') {
640     $format = $self->{_date_format}->{rdate}
641             ||= $self->conf->config('date_format') || '%m/%d/%Y';
642   } elsif ($format eq 'long') {
643     $format = $self->{_date_format}->{long}
644             ||= $self->conf->config('date_format_long') || '%b %o, %Y';
645   }
646
647   # actually render the date
648   my $string = $self->{_dh}->time2str($format, $time);
649
650   if ($escape) {
651     if ($escape eq 'html') {
652       $string = encode_entities($string);
653       $string =~ s/ +/&nbsp;/g;
654     } elsif ($escape eq 'latex') { # just do nbsp's here
655       $string =~ s/ +/~/g;
656     }
657   }
658   
659   $string;
660 }
661
662 =back
663
664 =head1 BUGS
665
666 =head1 SEE ALSO
667
668 L<FS::cust_main>, L<FS::Record>
669
670 =cut
671
672 1;
673