23dd508217730d29dc5a837cd2c94e122a6c7d9e
[kivitendo-erp.git] / SL / ReportGenerator.pm
1 package SL::ReportGenerator;
2
3 use IO::Wrap;
4 use List::Util qw(max);
5 use Text::CSV_XS;
6 use Text::Iconv;
7
8 use SL::Form;
9
10 # Cause locales.pl to parse these files:
11 # parse_html_template('report_generator/html_report')
12 # parse_html_template('report_generator/pdf_report')
13
14 sub new {
15   my $type = shift;
16
17   my $self = { };
18
19   $self->{myconfig} = shift;
20   $self->{form}     = shift;
21
22   $self->{data}     = [];
23   $self->{options}  = {
24     'std_column_visibility' => 0,
25     'output_format'         => 'HTML',
26     'allow_pdf_export'      => 1,
27     'allow_csv_export'      => 1,
28     'html_template'         => 'report_generator/html_report',
29     'pdf_template'          => 'report_generator/pdf_report',
30     'pdf_export'            => {
31       'paper_size'          => 'A4',
32       'orientation'         => 'landscape',
33       'font_size'           => '10',
34       'margin_top'          => 1.5,
35       'margin_left'         => 1.5,
36       'margin_bottom'       => 1.5,
37       'margin_right'        => 1.5,
38       'number'              => 1,
39       'print'               => 0,
40       'printer_id'          => 0,
41       'copies'              => 1,
42     },
43     'csv_export'            => {
44       'quote_char'          => '"',
45       'sep_char'            => ';',
46       'escape_char'         => '"',
47       'eol_style'           => 'Unix',
48       'headers'             => 1,
49     },
50   };
51   $self->{export}   = {
52     'nextsub'       => '',
53     'variable_list' => [],
54   };
55
56   $self->{data_present} = 0;
57
58   bless $self, $type;
59
60   $self->set_options(@_) if (@_);
61
62   $self->_init_escaped_strings_map();
63
64   return $self;
65 }
66
67 sub _init_escaped_strings_map {
68   my $self = shift;
69
70   $self->{escaped_strings_map} = {
71     'ä'  => 'ä',
72     'ö'  => 'ö',
73     'ü'  => 'ü',
74     'Ä'  => 'Ä',
75     'Ö'  => 'Ö',
76     'Ü'  => 'Ü',
77     'ß' => 'ß',
78     '>'    => '>',
79      '&lt;'    => '<',
80     '&quot;'  => '"',
81   };
82
83   my $iconv = $main::locale->{iconv_iso8859};
84
85   if ($iconv) {
86     map { $self->{escaped_strings_map}->{$_} = $iconv->convert($self->{escaped_strings_map}->{$_}) } keys %{ $self->{escaped_strings_map} };
87   }
88 }
89
90 sub set_columns {
91   my $self    = shift;
92   my %columns = @_;
93
94   $self->{columns} = \%columns;
95
96   foreach my $column (values %{ $self->{columns} }) {
97     $column->{visible} = $self->{options}->{std_column_visibility} unless defined $column->{visible};
98   }
99
100   $self->set_column_order(sort keys %{ $self->{columns} });
101 }
102
103 sub set_column_order {
104   my $self    = shift;
105
106   my $order   = 0;
107   my %columns = map { $order++; ($_, $order) } @_;
108
109   foreach my $column (sort keys %{ $self->{columns} }) {
110     next if $columns{$column};
111
112     $order++;
113     $columns{$column} = $order;
114   }
115
116   $self->{column_order} = [ sort { $columns{$a} <=> $columns{$b} } keys %columns ];
117 }
118
119 sub set_sort_indicator {
120   my $self = shift;
121
122   $self->{options}->{sort_indicator_column}    = shift;
123   $self->{options}->{sort_indicator_direction} = shift;
124 }
125
126 sub add_data {
127   my $self = shift;
128
129   my $last_row_set;
130
131   while (my $arg = shift) {
132     my $row_set;
133
134     if ('ARRAY' eq ref $arg) {
135       $row_set = $arg;
136
137     } elsif ('HASH' eq ref $arg) {
138       $row_set = [ $arg ];
139
140     } else {
141       $self->{form}->error('Incorrect usage -- expecting hash or array ref');
142     }
143
144     my @columns_with_default_alignment = grep { defined $self->{columns}->{$_}->{align} } keys %{ $self->{columns} };
145
146     foreach my $row (@{ $row_set }) {
147       foreach my $column (@columns_with_default_alignment) {
148         $row->{$column}          ||= { };
149         $row->{$column}->{align}   = $self->{columns}->{$column}->{align} unless (defined $row->{$column}->{align});
150       }
151
152       foreach my $field (qw(data link)) {
153         map { $row->{$_}->{$field} = [ $row->{$_}->{$field} ] if (ref $row->{$_}->{$field} ne 'ARRAY') } keys %{ $row };
154       }
155     }
156
157     push @{ $self->{data} }, $row_set;
158     $last_row_set = $row_set;
159
160     $self->{data_present} = 1;
161   }
162
163   return $last_row_set;
164 }
165
166 sub add_separator {
167   my $self = shift;
168
169   push @{ $self->{data} }, { 'type' => 'separator' };
170 }
171
172 sub add_control {
173   my $self = shift;
174   my $data = shift;
175
176   push @{ $self->{data} }, $data;
177 }
178
179 sub clear_data {
180   my $self = shift;
181
182   $self->{data}         = [];
183   $self->{data_present} = 0;
184 }
185
186 sub set_options {
187   my $self    = shift;
188   my %options = @_;
189
190   map { $self->{options}->{$_} = $options{$_} } keys %options;
191 }
192
193 sub set_options_from_form {
194   my $self     = shift;
195
196   my $form     = $self->{form};
197   my $myconfig = $self->{myconfig};
198
199   foreach my $key (qw(output_format)) {
200     my $full_key = "report_generator_${key}";
201     $self->{options}->{$key} = $form->{$full_key} if (defined $form->{$full_key});
202   }
203
204   foreach my $format (qw(pdf csv)) {
205     my $opts = $self->{options}->{"${format}_export"};
206     foreach my $key (keys %{ $opts }) {
207       my $full_key = "report_generator_${format}_options_${key}";
208       $opts->{$key} = $key =~ /^margin/ ? $form->parse_amount($myconfig, $form->{$full_key}) : $form->{$full_key};
209     }
210   }
211 }
212
213 sub set_export_options {
214   my $self        = shift;
215
216   $self->{export} = {
217     'nextsub'       => shift,
218     'variable_list' => [ @_ ],
219   };
220 }
221
222 sub get_attachment_basename {
223   my $self     = shift;
224   my $filename =  $self->{options}->{attachment_basename} || 'report';
225   $filename    =~ s|.*\\||;
226   $filename    =~ s|.*/||;
227
228   return $filename;
229 }
230
231 sub generate_with_headers {
232   my $self   = shift;
233   my $format = lc $self->{options}->{output_format};
234   my $form   = $self->{form};
235
236   if (!$self->{columns}) {
237     $form->error('Incorrect usage -- no columns specified');
238   }
239
240   if ($format eq 'html') {
241     my $title      = $form->{title};
242     $form->{title} = $self->{title} if ($self->{title});
243     $form->header();
244     $form->{title} = $title;
245
246     print $self->generate_html_content();
247
248   } elsif ($format eq 'csv') {
249     my $filename = $self->get_attachment_basename();
250     print qq|content-type: text/csv\n|;
251     print qq|content-disposition: attachment; filename=${filename}.csv\n\n|;
252     $self->generate_csv_content();
253
254   } elsif ($format eq 'pdf') {
255     $self->generate_pdf_content();
256
257   } else {
258     $form->error('Incorrect usage -- unknown format (supported are HTML, CSV, PDF)');
259   }
260 }
261
262 sub get_visible_columns {
263   my $self   = shift;
264   my $format = shift;
265
266   return grep { my $c = $self->{columns}->{$_}; $c && $c->{visible} && (($c->{visible} == 1) || ($c->{visible} =~ /\Q${format}\E/i)) } @{ $self->{column_order} };
267 }
268
269 sub html_format {
270   my $self  = shift;
271   my $value = shift;
272
273   $value =  $self->{form}->quote_html($value);
274   $value =~ s/\r//g;
275   $value =~ s/\n/<br>/g;
276
277   return $value;
278 }
279
280 sub prepare_html_content {
281   my $self = shift;
282
283   my ($column, $name, @column_headers);
284
285   my $opts            = $self->{options};
286   my @visible_columns = $self->get_visible_columns('HTML');
287
288   foreach $name (@visible_columns) {
289     $column = $self->{columns}->{$name};
290
291     my $header = {
292       'name'                     => $name,
293       'link'                     => $column->{link},
294       'text'                     => $column->{text},
295       'show_sort_indicator'      => $name eq $opts->{sort_indicator_column},
296       'sort_indicator_direction' => $opts->{sort_indicator_direction},
297     };
298
299     push @column_headers, $header;
300   }
301
302   my ($outer_idx, $inner_idx) = (0, 0);
303   my $next_border_top;
304   my @rows;
305
306   foreach my $row_set (@{ $self->{data} }) {
307     if ('HASH' eq ref $row_set) {
308       if ($row_set->{type} eq 'separator') {
309         if (! scalar @rows) {
310           $next_border_top = 1;
311         } else {
312           $rows[-1]->{BORDER_BOTTOM} = 1;
313         }
314
315         next;
316       }
317
318       my $row_data = {
319         'IS_CONTROL'      => 1,
320         'IS_COLSPAN_DATA' => $row_set->{type} eq 'colspan_data',
321         'NUM_COLUMNS'     => scalar @visible_columns,
322         'BORDER_TOP'      => $next_border_top,
323         'data'            => $row_set->{data},
324       };
325
326       push @rows, $row_data;
327
328       $next_border_top = 0;
329
330       next;
331     }
332
333     $outer_idx++;
334
335     foreach my $row (@{ $row_set }) {
336       $inner_idx++;
337
338       foreach my $col_name (@visible_columns) {
339         my $col = $row->{$col_name};
340         $col->{CELL_ROWS} = [ ];
341         foreach my $i (0 .. scalar(@{ $col->{data} }) - 1) {
342           push @{ $col->{CELL_ROWS} }, {
343             'data' => $self->html_format($col->{data}->[$i]),
344             'link' => $col->{link}->[$i],
345           };
346         }
347
348         # Force at least a &nbsp; to be displayed so that browsers
349         # will format the table cell (e.g. borders etc).
350         if (!scalar @{ $col->{CELL_ROWS} }) {
351           push @{ $col->{CELL_ROWS} }, { 'data' => '&nbsp;' };
352         } elsif ((1 == scalar @{ $col->{CELL_ROWS} }) && (!defined $col->{CELL_ROWS}->[0]->{data} || ($col->{CELL_ROWS}->[0]->{data} eq ''))) {
353           $col->{CELL_ROWS}->[0]->{data} = '&nbsp;';
354         }
355       }
356
357       my $row_data = {
358         'COLUMNS'       => [ map { $row->{$_} } @visible_columns ],
359         'outer_idx'     => $outer_idx,
360         'outer_idx_odd' => $outer_idx % 2,
361         'inner_idx'     => $inner_idx,
362         'BORDER_TOP'    => $next_border_top,
363       };
364
365       push @rows, $row_data;
366
367       $next_border_top = 0;
368     }
369   }
370
371   my @export_variables = $self->{form}->flatten_variables(@{ $self->{export}->{variable_list} });
372
373   my $allow_pdf_export = $opts->{allow_pdf_export} && (-x $main::html2ps_bin) && (-x $main::ghostscript_bin);
374
375   my $variables = {
376     'TITLE'                => $opts->{title},
377     'TOP_INFO_TEXT'        => $self->html_format($opts->{top_info_text}),
378     'RAW_TOP_INFO_TEXT'    => $opts->{raw_top_info_text},
379     'BOTTOM_INFO_TEXT'     => $self->html_format($opts->{bottom_info_text}),
380     'RAW_BOTTOM_INFO_TEXT' => $opts->{raw_bottom_info_text},
381     'ALLOW_PDF_EXPORT'     => $allow_pdf_export,
382     'ALLOW_CSV_EXPORT'     => $opts->{allow_csv_export},
383     'SHOW_EXPORT_BUTTONS'  => ($allow_pdf_export || $opts->{allow_csv_export}) && $self->{data_present},
384     'COLUMN_HEADERS'       => \@column_headers,
385     'NUM_COLUMNS'          => scalar @column_headers,
386     'ROWS'                 => \@rows,
387     'EXPORT_VARIABLES'     => \@export_variables,
388     'EXPORT_VARIABLE_LIST' => join(' ', @{ $self->{export}->{variable_list} }),
389     'EXPORT_NEXTSUB'       => $self->{export}->{nextsub},
390     'DATA_PRESENT'         => $self->{data_present},
391   };
392
393   return $variables;
394 }
395
396 sub generate_html_content {
397   my $self      = shift;
398   my $variables = $self->prepare_html_content();
399
400   return $self->{form}->parse_html_template($self->{options}->{html_template}, $variables);
401 }
402
403 sub verify_paper_size {
404   my $self                 = shift;
405   my $requested_paper_size = lc shift;
406   my $default_paper_size   = shift;
407
408   my %allowed_paper_sizes  = map { $_ => 1 } qw(a3 a4 letter legal);
409
410   return $allowed_paper_sizes{$requested_paper_size} ? $requested_paper_size : $default_paper_size;
411 }
412
413 sub generate_pdf_content {
414   my $self      = shift;
415   my $variables = $self->prepare_html_content();
416   my $form      = $self->{form};
417   my $myconfig  = $self->{myconfig};
418   my $opt       = $self->{options}->{pdf_export};
419
420   my $opt_number     = $opt->{number}                     ? 'number : 1'    : '';
421   my $opt_landscape  = $opt->{orientation} eq 'landscape' ? 'landscape : 1' : '';
422
423   my $opt_paper_size = $self->verify_paper_size($opt->{paper_size}, 'a4');
424
425   my $html2ps_config = <<"END"
426 \@html2ps {
427   option {
428     titlepage: 0;
429     hyphenate: 0;
430     colour: 1;
431     ${opt_landscape};
432     ${opt_number};
433   }
434   paper {
435     type: ${opt_paper_size};
436   }
437   break-table: 1;
438 }
439
440 \@page {
441   margin-top:    $opt->{margin_top}cm;
442   margin-left:   $opt->{margin_left}cm;
443   margin-bottom: $opt->{margin_bottom}cm;
444   margin-right:  $opt->{margin_right}cm;
445 }
446
447 BODY {
448   font-family: Helvetica;
449   font-size:   $opt->{font_size}pt;
450 }
451
452 END
453   ;
454
455   my $printer_command;
456   if ($opt->{print} && $opt->{printer_id}) {
457     $form->{printer_id} = $opt->{printer_id};
458     $form->get_printer_code($myconfig);
459     $printer_command = $form->{printer_command};
460   }
461
462   my $cfg_file_name = Common::tmpname() . '-html2ps-config';
463   my $cfg_file      = IO::File->new($cfg_file_name, 'w') || $form->error($locale->text('Could not write the html2ps config file.'));
464
465   $cfg_file->print($html2ps_config);
466   $cfg_file->close();
467
468   my $html_file_name = Common::tmpname() . '.html';
469   my $html_file      = IO::File->new($html_file_name, 'w');
470
471   if (!$html_file) {
472     unlink $cfg_file_name;
473     $form->error($locale->text('Could not write the temporary HTML file.'));
474   }
475
476   $html_file->print($form->parse_html_template($self->{options}->{pdf_template}, $variables));
477   $html_file->close();
478
479   my $cmdline =
480     "\"${main::html2ps_bin}\" -f \"${cfg_file_name}\" \"${html_file_name}\" | " .
481     "\"${main::ghostscript_bin}\" -q -dSAFER -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sPAPERSIZE=${opt_paper_size} -sOutputFile=- -c .setpdfwrite -";
482
483   my $gs = IO::File->new("${cmdline} |");
484   if ($gs) {
485     my $content;
486
487     if (!$printer_command) {
488       my $filename = $self->get_attachment_basename();
489       print qq|content-type: application/pdf\n|;
490       print qq|content-disposition: attachment; filename=${filename}.pdf\n\n|;
491
492       while (my $line = <$gs>) {
493         print $line;
494       }
495
496     } else {
497       while (my $line = <$gs>) {
498         $content .= $line;
499       }
500     }
501
502     $gs->close();
503     unlink $cfg_file_name, $html_file_name;
504
505     if ($printer_command && $content) {
506       foreach my $i (1 .. max $opt->{copies}, 1) {
507         my $printer = IO::File->new("| ${printer_command}");
508         if (!$printer) {
509           $form->error($locale->text('Could not spawn the printer command.'));
510         }
511         $printer->print($content);
512         $printer->close();
513       }
514
515       $form->{report_generator_printed} = 1;
516     }
517
518   } else {
519     unlink $cfg_file_name, $html_file_name;
520     $form->error($locale->text('Could not spawn html2ps or GhostScript.'));
521   }
522 }
523
524 sub unescape_string {
525   my $self = shift;
526   my $text = shift;
527
528   foreach my $key (keys %{ $self->{escaped_strings_map} }) {
529     $text =~ s/\Q$key\E/$self->{escaped_strings_map}->{$key}/g;
530   }
531
532   $text =~ s/\Q&amp;\E/&/g;
533
534   return $text;
535 }
536
537 sub generate_csv_content {
538   my $self = shift;
539
540   my %valid_sep_chars    = (';' => ';', ',' => ',', ':' => ':', 'TAB' => "\t");
541   my %valid_escape_chars = ('"' => 1, "'" => 1);
542   my %valid_quote_chars  = ('"' => 1, "'" => 1);
543
544   my $opts        = $self->{options}->{csv_export};
545   my $eol         = $opts->{eol_style} eq 'DOS'               ? "\r\n"                              : "\n";
546   my $sep_char    = $valid_sep_chars{$opts->{sep_char}}       ? $valid_sep_chars{$opts->{sep_char}} : ';';
547   my $escape_char = $valid_escape_chars{$opts->{escape_char}} ? $opts->{escape_char}                : '"';
548   my $quote_char  = $valid_quote_chars{$opts->{quote_char}}   ? $opts->{quote_char}                 : '"';
549
550   $escape_char    = $quote_char if ($opts->{escape_char} eq 'QUOTE_CHAR');
551
552   my $csv = Text::CSV_XS->new({ 'binary'      => 1,
553                                 'sep_char'    => $sep_char,
554                                 'escape_char' => $escape_char,
555                                 'quote_char'  => $quote_char,
556                                 'eol'         => $eol, });
557
558   my $stdout          = wraphandle(\*STDOUT);
559   my @visible_columns = $self->get_visible_columns('CSV');
560
561   if ($opts->{headers}) {
562     $csv->print($stdout, [ map { $self->unescape_string($self->{columns}->{$_}->{text}) } @visible_columns ]);
563   }
564
565   foreach my $row_set (@{ $self->{data} }) {
566     next if ('ARRAY' ne ref $row_set);
567     foreach my $row (@{ $row_set }) {
568       my @data;
569       foreach my $col (@visible_columns) {
570         push @data, join($eol, map { s/\r?\n/$eol/g; $_ } @{ $row->{$col}->{data} });
571       }
572       $csv->print($stdout, \@data);
573     }
574   }
575 }
576
577 1;