fix some report column names/expressions, #940, #25161
[freeside.git] / FS / FS / UI / Web.pm
1 package FS::UI::Web;
2
3 use strict;
4 use vars qw($DEBUG @ISA @EXPORT_OK $me);
5 use Exporter;
6 use Carp qw( confess );
7 use HTML::Entities;
8 use FS::Conf;
9 use FS::Misc::DateTime qw( parse_datetime day_end );
10 use FS::Record qw(dbdef);
11 use FS::cust_main;  # are sql_balance and sql_date_balance in the right module?
12
13 #use vars qw(@ISA);
14 #use FS::UI
15 #@ISA = qw( FS::UI );
16 @ISA = qw( Exporter );
17
18 @EXPORT_OK = qw( svc_url );
19
20 $DEBUG = 0;
21 $me = '[FS::UID::Web]';
22
23 ###
24 # date parsing
25 ###
26
27 use Date::Parse;
28 sub parse_beginning_ending {
29   my($cgi, $prefix) = @_;
30   $prefix .= '_' if $prefix;
31
32   my $beginning = 0;
33   if ( $cgi->param($prefix.'begin') =~ /^(\d+)$/ ) {
34     $beginning = $1;
35   } elsif ( $cgi->param($prefix.'beginning') =~ /^([ 0-9\-\/\:]{1,64})$/ ) {
36     $beginning = parse_datetime($1) || 0;
37   }
38
39   my $ending = 4294967295; #2^32-1
40   if ( $cgi->param($prefix.'end') =~ /^(\d+)$/ ) {
41     $ending = $1 - 1;
42   } elsif ( $cgi->param($prefix.'ending') =~ /^([ 0-9\-\/\:]{1,64})$/ ) {
43     $ending = parse_datetime($1);
44     $ending = day_end($ending) unless $ending =~ /:/;
45   }
46
47   ( $beginning, $ending );
48 }
49
50 =item svc_url
51
52 Returns a service URL, first checking to see if there is a service-specific
53 page to link to, otherwise to a generic service handling page.  Options are
54 passed as a list of name-value pairs, and include:
55
56 =over 4
57
58 =item * m - Mason request object ($m)
59
60 =item * action - The action for which to construct "edit", "view", or "search"
61
62 =item ** part_svc - Service definition (see L<FS::part_svc>)
63
64 =item ** svcdb - Service table
65
66 =item *** query - Query string
67
68 =item *** svc   - FS::cust_svc or FS::svc_* object
69
70 =item ahref - Optional flag, if set true returns <A HREF="$url"> instead of just the URL.
71
72 =back 
73
74 * Required fields
75
76 ** part_svc OR svcdb is required
77
78 *** query OR svc is required
79
80 =cut
81
82   # ##
83   # #required
84   # ##
85   #  'm'        => $m, #mason request object
86   #  'action'   => 'edit', #or 'view'
87   #
88   #  'part_svc' => $part_svc, #usual
89   #   #OR
90   #  'svcdb'    => 'svc_table',
91   #
92   #  'query'    => #optional query string
93   #                # (pass a blank string if you want a "raw" URL to add your
94   #                #  own svcnum to)
95   #   #OR
96   #  'svc'      => $svc_x, #or $cust_svc, it just needs a svcnum
97   #
98   # ##
99   # #optional
100   # ##
101   #  'ahref'    => 1, # if set true, returns <A HREF="$url">
102
103 use FS::CGI qw(rooturl);
104 sub svc_url {
105   my %opt = @_;
106
107   #? return '' unless ref($opt{part_svc});
108
109   my $svcdb = $opt{svcdb} || $opt{part_svc}->svcdb;
110   my $query = exists($opt{query}) ? $opt{query} : $opt{svc}->svcnum;
111   my $url;
112   warn "$me [svc_url] checking for /$opt{action}/$svcdb.cgi component"
113     if $DEBUG;
114   if ( $opt{m}->interp->comp_exists("/$opt{action}/$svcdb.cgi") ) {
115     $url = "$svcdb.cgi?";
116   } else {
117
118     my $generic = $opt{action} eq 'search' ? 'cust_svc' : 'svc_Common';
119
120     $url = "$generic.html?svcdb=$svcdb;";
121     $url .= 'svcnum=' if $query =~ /^\d+(;|$)/ or $query eq '';
122   }
123
124   import FS::CGI 'rooturl'; #WTF!  why is this necessary
125   my $return = rooturl(). "$opt{action}/$url$query";
126
127   $return = qq!<A HREF="$return">! if $opt{ahref};
128
129   $return;
130 }
131
132 sub svc_link {
133   my($m, $part_svc, $cust_svc) = @_ or return '';
134   svc_X_link( $part_svc->svc, @_ );
135 }
136
137 sub svc_label_link {
138   my($m, $part_svc, $cust_svc) = @_ or return '';
139   my($svc, $label, $svcdb) = $cust_svc->label;
140   svc_X_link( $label, @_ );
141 }
142
143 sub svc_X_link {
144   my ($x, $m, $part_svc, $cust_svc) = @_ or return '';
145
146   return $x
147    unless $FS::CurrentUser::CurrentUser->access_right('View customer services');
148
149   confess "svc_X_link called without a service ($x, $m, $part_svc, $cust_svc)\n"
150     unless $cust_svc;
151
152   my $ahref = svc_url(
153     'ahref'    => 1,
154     'm'        => $m,
155     'action'   => 'view',
156     'part_svc' => $part_svc,
157     'svc'      => $cust_svc,
158   );
159
160   "$ahref$x</A>";
161 }
162
163 #this probably needs an ACL too...
164 sub svc_export_links {
165   my ($m, $part_svc, $cust_svc) = @_ or return '';
166
167   my $ahref = $cust_svc->export_links;
168
169   join('', @$ahref);
170 }
171
172 sub parse_lt_gt {
173   my($cgi, $field) = @_;
174
175   my @search = ();
176
177   my %op = ( 
178     'lt' => '<',
179     'gt' => '>',
180   );
181
182   foreach my $op (keys %op) {
183
184     warn "checking for ${field}_$op field\n"
185       if $DEBUG;
186
187     if ( $cgi->param($field."_$op") =~ /^\s*\$?\s*(-?[\d\,\s]+(\.\d\d)?)\s*$/ ) {
188
189       my $num = $1;
190       $num =~ s/[\,\s]+//g;
191       my $search = "$field $op{$op} $num";
192       push @search, $search;
193
194       warn "found ${field}_$op field; adding search element $search\n"
195         if $DEBUG;
196     }
197
198   }
199
200   @search;
201
202 }
203
204 ###
205 # cust_main report subroutines
206 ###
207
208
209 =item cust_header [ CUST_FIELDS_VALUE ]
210
211 Returns an array of customer information headers according to the supplied
212 customer fields value, or if no value is supplied, the B<cust-fields>
213 configuration value.
214
215 =cut
216
217 use vars qw( @cust_fields @cust_colors @cust_styles @cust_aligns );
218
219 sub cust_header {
220
221   warn "FS::UI:Web::cust_header called"
222     if $DEBUG;
223
224   my $conf = new FS::Conf;
225
226   my %header2method = (
227     'Customer'                 => 'name',
228     'Cust. Status'             => 'ucfirst_cust_status',
229     'Cust#'                    => 'custnum',
230     'Name'                     => 'contact',
231     'Company'                  => 'company',
232
233     # obsolete but might still be referenced in configuration
234     '(bill) Customer'          => 'name',
235     '(service) Customer'       => 'ship_name',
236     '(bill) Name'              => 'contact',
237     '(service) Name'           => 'ship_contact',
238     '(bill) Company'           => 'company',
239     '(service) Company'        => 'ship_company',
240     '(bill) Day phone'         => 'daytime',
241     '(bill) Night phone'       => 'night',
242     '(bill) Fax number'        => 'fax',
243  
244     'Customer'                 => 'name',
245     'Address 1'                => 'bill_address1',
246     'Address 2'                => 'bill_address2',
247     'City'                     => 'bill_city',
248     'State'                    => 'bill_state',
249     'Zip'                      => 'bill_zip',
250     'Country'                  => 'bill_country_full',
251     'Day phone'                => 'daytime', # XXX should use msgcat, but how?
252     'Night phone'              => 'night',   # XXX should use msgcat, but how?
253     'Fax number'               => 'fax',
254     '(bill) Address 1'         => 'bill_address1',
255     '(bill) Address 2'         => 'bill_address2',
256     '(bill) City'              => 'bill_city',
257     '(bill) State'             => 'bill_state',
258     '(bill) Zip'               => 'bill_zip',
259     '(bill) Country'           => 'bill_country_full',
260     '(service) Address 1'      => 'ship_address1',
261     '(service) Address 2'      => 'ship_address2',
262     '(service) City'           => 'ship_city',
263     '(service) State'          => 'ship_state',
264     '(service) Zip'            => 'ship_zip',
265     '(service) Country'        => 'ship_country_full',
266     'Invoicing email(s)'       => 'invoicing_list_emailonly_scalar',
267     'Payment Type'             => 'payby',
268     'Current Balance'          => 'current_balance',
269   );
270   $header2method{'Cust#'} = 'display_custnum'
271     if $conf->exists('cust_main-default_agent_custid');
272
273   my %header2colormethod = (
274     'Cust. Status' => 'cust_statuscolor',
275   );
276   my %header2style = (
277     'Cust. Status' => 'b',
278   );
279   my %header2align = (
280     'Cust. Status' => 'c',
281     'Cust#'        => 'r',
282   );
283
284   my $cust_fields;
285   my @cust_header;
286   if ( @_ && $_[0] ) {
287
288     warn "  using supplied cust-fields override".
289           " (ignoring cust-fields config file)"
290       if $DEBUG;
291     $cust_fields = shift;
292
293   } else {
294
295     if (    $conf->exists('cust-fields')
296          && $conf->config('cust-fields') =~ /^([\w\. \|\#\(\)]+):?/
297        )
298     {
299       warn "  found cust-fields configuration value"
300         if $DEBUG;
301       $cust_fields = $1;
302     } else { 
303       warn "  no cust-fields configuration value found; using default 'Cust. Status | Customer'"
304         if $DEBUG;
305       $cust_fields = 'Cust. Status | Customer';
306     }
307   
308   }
309
310   @cust_header = split(/ \| /, $cust_fields);
311   @cust_fields = map { $header2method{$_} || $_ } @cust_header;
312   @cust_colors = map { exists $header2colormethod{$_}
313                          ? $header2colormethod{$_}
314                          : ''
315                      }
316                      @cust_header;
317   @cust_styles = map { exists $header2style{$_} ? $header2style{$_} : '' }
318                      @cust_header;
319   @cust_aligns = map { exists $header2align{$_} ? $header2align{$_} : 'l' }
320                      @cust_header;
321
322   #my $svc_x = shift;
323   @cust_header;
324 }
325
326 =item cust_sql_fields [ CUST_FIELDS_VALUE ]
327
328 Returns a list of fields for the SELECT portion of an SQL query.
329
330 As with L<the cust_header subroutine|/cust_header>, the fields returned are
331 defined by the supplied customer fields setting, or if no customer fields
332 setting is supplied, the <B>cust-fields</B> configuration value. 
333
334 =cut
335
336 sub cust_sql_fields {
337
338   my @fields = qw( last first company );
339 #  push @fields, map "ship_$_", @fields;
340
341   cust_header(@_);
342   #inefficientish, but tiny lists and only run once per page
343
344   my @location_fields;
345   foreach my $field (qw( address1 address2 city state zip )) {
346     foreach my $pre ('bill_','ship_') {
347       if ( grep { $_ eq $pre.$field } @cust_fields ) {
348         push @location_fields, $pre.'location.'.$field.' AS '.$pre.$field;
349       }
350     }
351   }
352
353   foreach my $field (qw(daytime night fax payby)) {
354     push @fields, $field if (grep { $_ eq $field } @cust_fields);
355   }
356   push @fields, 'agent_custid';
357
358   my @extra_fields = ();
359   if (grep { $_ eq 'current_balance' } @cust_fields) {
360     push @extra_fields, FS::cust_main->balance_sql . " AS current_balance";
361   }
362
363   map("cust_main.$_", @fields), @location_fields, @extra_fields;
364 }
365
366 =item join_cust_main [ TABLE[.CUSTNUM] ] [ LOCATION_TABLE[.LOCATIONNUM] ]
367
368 Returns an SQL join phrase for the FROM clause so that the fields listed
369 in L<cust_sql_fields> will be available.  Currently joins to cust_main 
370 itself, as well as cust_location (under the aliases 'bill_location' and
371 'ship_location') if address fields are needed.  L<cust_header()> should have
372 been called already.
373
374 All of these will be left joins; if you want to exclude rows with no linked
375 cust_main record (or bill_location/ship_location), you can do so in the 
376 WHERE clause.
377
378 TABLE is the table containing the custnum field.  If CUSTNUM (a field name
379 in that table) is specified, that field will be joined to cust_main.custnum.
380 Otherwise, this function will assume the field is named "custnum".  If the 
381 argument isn't present at all, the join will just say "USING (custnum)", 
382 which might work.
383
384 As a special case, if TABLE is 'cust_main', only the joins to cust_location
385 will be returned.
386
387 LOCATION_TABLE is an optional table name to use for joining ship_location,
388 in case your query also includes package information and you want the 
389 "service address" columns to reflect package addresses.
390
391 =cut
392
393 sub join_cust_main {
394   my ($cust_table, $location_table) = @_;
395   my ($custnum, $locationnum);
396   ($cust_table, $custnum) = split(/\./, $cust_table);
397   $custnum ||= 'custnum';
398   ($location_table, $locationnum) = split(/\./, $location_table);
399   $locationnum ||= 'locationnum';
400
401   my $sql = '';
402   if ( $cust_table ) {
403     $sql = " LEFT JOIN cust_main ON (cust_main.custnum = $cust_table.$custnum)"
404       unless $cust_table eq 'cust_main';
405   } else {
406     $sql = " LEFT JOIN cust_main USING (custnum)";
407   }
408
409   if ( !@cust_fields or grep /^bill_/, @cust_fields ) {
410
411     $sql .= ' LEFT JOIN cust_location bill_location'.
412             ' ON (bill_location.locationnum = cust_main.bill_locationnum)';
413
414   }
415
416   if ( !@cust_fields or grep /^ship_/, @cust_fields ) {
417
418     if (!$location_table) {
419       $location_table = 'cust_main';
420       $locationnum = 'ship_locationnum';
421     }
422
423     $sql .= ' LEFT JOIN cust_location ship_location'.
424             " ON (ship_location.locationnum = $location_table.$locationnum) ";
425   }
426
427   $sql;
428 }
429
430 =item cust_fields OBJECT [ CUST_FIELDS_VALUE ]
431
432 Given an object that contains fields from cust_main (say, from a
433 JOINed search.  See httemplate/search/svc_* for examples), returns an array
434 of customer information, or "(unlinked)" if this service is not linked to a
435 customer.
436
437 As with L<the cust_header subroutine|/cust_header>, the fields returned are
438 defined by the supplied customer fields setting, or if no customer fields
439 setting is supplied, the <B>cust-fields</B> configuration value. 
440
441 =cut
442
443
444 sub cust_fields {
445   my $record = shift;
446   warn "FS::UI::Web::cust_fields called for $record ".
447        "(cust_fields: @cust_fields)"
448     if $DEBUG > 1;
449
450   #cust_header(@_) unless @cust_fields; #now need to cache to keep cust_fields
451   #                                     #override incase we were passed as a sub
452   
453   my $seen_unlinked = 0;
454
455   map { 
456     if ( $record->custnum ) {
457       warn "  $record -> $_" if $DEBUG > 1;
458       encode_entities( $record->$_(@_) );
459     } else {
460       warn "  ($record unlinked)" if $DEBUG > 1;
461       $seen_unlinked++ ? '' : '(unlinked)';
462     }
463   } @cust_fields;
464 }
465
466 =item cust_fields_subs
467
468 Returns an array of subroutine references for returning customer field values.
469 This is similar to cust_fields, but returns each field's sub as a distinct 
470 element.
471
472 =cut
473
474 sub cust_fields_subs {
475   my $unlinked_warn = 0;
476   return map { 
477     my $f = $_;
478     if ( $unlinked_warn++ ) {
479
480       sub {
481         my $record = shift;
482         if ( $record->custnum ) {
483           encode_entities( $record->$f(@_) );
484         } else {
485           '(unlinked)'
486         };
487       };
488
489     } else {
490
491       sub {
492         my $record = shift;
493         $record->custnum ? encode_entities( $record->$f(@_) ) : '';
494       };
495
496     }
497
498   } @cust_fields;
499 }
500
501 =item cust_colors
502
503 Returns an array of subroutine references (or empty strings) for returning
504 customer information colors.
505
506 As with L<the cust_header subroutine|/cust_header>, the fields returned are
507 defined by the supplied customer fields setting, or if no customer fields
508 setting is supplied, the <B>cust-fields</B> configuration value. 
509
510 =cut
511
512 sub cust_colors {
513   map { 
514     my $method = $_;
515     if ( $method ) {
516       sub { shift->$method(@_) };
517     } else {
518       '';
519     }
520   } @cust_colors;
521 }
522
523 =item cust_styles
524
525 Returns an array of customer information styles.
526
527 As with L<the cust_header subroutine|/cust_header>, the fields returned are
528 defined by the supplied customer fields setting, or if no customer fields
529 setting is supplied, the <B>cust-fields</B> configuration value. 
530
531 =cut
532
533 sub cust_styles {
534   map { 
535     if ( $_ ) {
536       $_;
537     } else {
538       '';
539     }
540   } @cust_styles;
541 }
542
543 =item cust_aligns
544
545 Returns an array or scalar (depending on context) of customer information
546 alignments.
547
548 As with L<the cust_header subroutine|/cust_header>, the fields returned are
549 defined by the supplied customer fields setting, or if no customer fields
550 setting is supplied, the <B>cust-fields</B> configuration value. 
551
552 =cut
553
554 sub cust_aligns {
555   if ( wantarray ) {
556     @cust_aligns;
557   } else {
558     join('', @cust_aligns);
559   }
560 }
561
562 =item is_mobile
563
564 Utility function to determine if the client is a mobile browser.
565
566 =cut
567
568 sub is_mobile {
569   my $ua = $ENV{'HTTP_USER_AGENT'} || '';
570   if ( $ua =~ /(?:hiptop|Blazer|Novarra|Vagabond|SonyEricsson|Symbian|NetFront|UP.Browser|UP.Link|Windows CE|MIDP|J2ME|DoCoMo|J-PHONE|PalmOS|PalmSource|iPhone|iPod|AvantGo|Nokia|Android|WebOS|S60|Opera Mini|Opera Mobi)/io ) {
571     return 1;
572   }
573   return 0;
574 }
575     
576 ###
577 # begin JSRPC code...
578 ###
579
580 package FS::UI::Web::JSRPC;
581
582 use strict;
583 use vars qw($DEBUG);
584 use Carp;
585 use Storable qw(nfreeze);
586 use MIME::Base64;
587 use JSON::XS;
588 use FS::CurrentUser;
589 use FS::Record qw(qsearchs);
590 use FS::queue;
591 use FS::CGI qw(rooturl);
592
593 $DEBUG = 0;
594
595 sub new {
596         my $class = shift;
597         my $self  = {
598                 env => {},
599                 job => shift,
600                 cgi => shift,
601         };
602
603         bless $self, $class;
604
605         croak "CGI object required as second argument" unless $self->{'cgi'};
606
607         return $self;
608 }
609
610 sub process {
611
612   my $self = shift;
613
614   my $cgi = $self->{'cgi'};
615
616   # XXX this should parse JSON foo and build a proper data structure
617   my @args = $cgi->param('arg');
618
619   #work around konqueror bug!
620   @args = map { s/\x00$//; $_; } @args;
621
622   my $sub = $cgi->param('sub'); #????
623
624   warn "FS::UI::Web::JSRPC::process:\n".
625        "  cgi=$cgi\n".
626        "  sub=$sub\n".
627        "  args=".join(', ',@args)."\n"
628     if $DEBUG;
629
630   if ( $sub eq 'start_job' ) {
631
632     $self->start_job(@args);
633
634   } elsif ( $sub eq 'job_status' ) {
635
636     $self->job_status(@args);
637
638   } else {
639
640     die "unknown sub $sub";
641
642   }
643
644 }
645
646 sub start_job {
647   my $self = shift;
648
649   warn "FS::UI::Web::start_job: ". join(', ', @_) if $DEBUG;
650 #  my %param = @_;
651   my %param = ();
652   while ( @_ ) {
653     my( $field, $value ) = splice(@_, 0, 2);
654     unless ( exists( $param{$field} ) ) {
655       $param{$field} = $value;
656     } elsif ( ! ref($param{$field}) ) {
657       $param{$field} = [ $param{$field}, $value ];
658     } else {
659       push @{$param{$field}}, $value;
660     }
661   }
662   $param{CurrentUser} = $FS::CurrentUser::CurrentUser->username;
663   $param{RootURL} = rooturl($self->{cgi}->self_url);
664   warn "FS::UI::Web::start_job\n".
665        join('', map {
666                       if ( ref($param{$_}) ) {
667                         "  $_ => [ ". join(', ', @{$param{$_}}). " ]\n";
668                       } else {
669                         "  $_ => $param{$_}\n";
670                       }
671                     } keys %param )
672     if $DEBUG;
673
674   #first get the CGI params shipped off to a job ASAP so an id can be returned
675   #to the caller
676   
677   my $job = new FS::queue { 'job' => $self->{'job'} };
678   
679   #too slow to insert all the cgi params as individual args..,?
680   #my $error = $queue->insert('_JOB', $cgi->Vars);
681   
682   #warn 'froze string of size '. length(nfreeze(\%param)). " for job args\n"
683   #  if $DEBUG;
684   #
685   #  XXX FS::queue::insert knows how to do this.
686   #  not changing it here because that requires changing it everywhere else,
687   #  too, but we should eventually fix it
688
689   my $error = $job->insert( '_JOB', encode_base64(nfreeze(\%param)) );
690
691   if ( $error ) {
692
693     warn "job not inserted: $error\n"
694       if $DEBUG;
695
696     $error;  #this doesn't seem to be handled well,
697              # will trigger "illegal jobnum" below?
698              # (should never be an error inserting the job, though, only thing
699              #  would be Pg f%*kage)
700   } else {
701
702     warn "job inserted successfully with jobnum ". $job->jobnum. "\n"
703       if $DEBUG;
704
705     $job->jobnum;
706   }
707   
708 }
709
710 sub job_status {
711   my( $self, $jobnum ) = @_; #$url ???
712
713   sleep 1; # XXX could use something better...
714
715   my $job;
716   if ( $jobnum =~ /^(\d+)$/ ) {
717     $job = qsearchs('queue', { 'jobnum' => $jobnum } );
718   } else {
719     die "FS::UI::Web::job_status: illegal jobnum $jobnum\n";
720   }
721
722   my @return;
723   if ( $job && $job->status ne 'failed' && $job->status ne 'done' ) {
724     my ($progress, $action) = split ',', $job->statustext, 2; 
725     $action ||= 'Server processing job';
726     @return = ( 'progress', $progress, $action );
727   } elsif ( !$job ) { #handle job gone case : job successful
728                       # so close popup, redirect parent window...
729     @return = ( 'complete' );
730   } elsif ( $job->status eq 'done' ) {
731     @return = ( 'done', $job->statustext, '' );
732   } else {
733     @return = ( 'error', $job ? $job->statustext : $jobnum );
734   }
735
736   encode_json \@return;
737
738 }
739
740 1;
741