wiederkehrende Rechnungen: auch bei E-Mail erzeugtes Dokument im DMS speichern
[kivitendo-erp.git] / SL / BackgroundJob / CreatePeriodicInvoices.pm
1 package SL::BackgroundJob::CreatePeriodicInvoices;
2
3 use strict;
4
5 use parent qw(SL::BackgroundJob::Base);
6
7 use Config::Std;
8 use DateTime::Format::Strptime;
9 use English qw(-no_match_vars);
10 use List::MoreUtils qw(uniq);
11
12 use SL::Common;
13 use SL::DB::AuthUser;
14 use SL::DB::Default;
15 use SL::DB::Order;
16 use SL::DB::Invoice;
17 use SL::DB::PeriodicInvoice;
18 use SL::DB::PeriodicInvoicesConfig;
19 use SL::File;
20 use SL::Helper::CreatePDF qw(create_pdf find_template);
21 use SL::Mailer;
22 use SL::Util qw(trim);
23 use SL::System::Process;
24
25 sub create_job {
26   $_[0]->create_standard_job('0 3 1 * *'); # first day of month at 3:00 am
27 }
28
29 sub run {
30   my $self        = shift;
31   $self->{db_obj} = shift;
32
33   $self->{$_} = [] for qw(job_errors posted_invoices printed_invoices printed_failed emailed_invoices emailed_failed disabled_orders);
34
35   if (!$self->{db_obj}->db->with_transaction(sub {
36     1;                          # make Emacs happy
37
38     my $configs = SL::DB::Manager::PeriodicInvoicesConfig->get_all(query => [ active => 1 ]);
39
40     foreach my $config (@{ $configs }) {
41       my $new_end_date = $config->handle_automatic_extension;
42       _log_msg("Periodic invoice configuration ID " . $config->id . " extended through " . $new_end_date->strftime('%d.%m.%Y') . "\n") if $new_end_date;
43     }
44
45     my (@invoices_to_print, @invoices_to_email);
46
47     _log_msg("Number of configs: " . scalar(@{ $configs}));
48
49     foreach my $config (@{ $configs }) {
50       # A configuration can be set to inactive by
51       # $config->handle_automatic_extension. Therefore the check in
52       # ...->get_all() does not suffice.
53       _log_msg("Config " . $config->id . " active " . $config->active);
54       next unless $config->active;
55
56       my @dates = _calculate_dates($config);
57
58       _log_msg("Dates: " . join(' ', map { $_->to_lxoffice } @dates));
59
60       foreach my $date (@dates) {
61         my $data = $self->_create_periodic_invoice($config, $date);
62         next unless $data;
63
64         _log_msg("Invoice " . $data->{invoice}->invnumber . " posted for config ID " . $config->id . ", period start date " . $::locale->format_date(\%::myconfig, $date) . "\n");
65
66         push @{ $self->{posted_invoices} }, $data->{invoice};
67         push @invoices_to_print, $data if $config->print;
68         push @invoices_to_email, $data if $config->send_email;
69
70         my $inactive_ordnumber = $config->disable_one_time_config;
71         if ($inactive_ordnumber) {
72           # disable one time configs and skip eventual invoices
73           _log_msg("Order " . $inactive_ordnumber . " deavtivated \n");
74           push @{ $self->{disabled_orders} }, $inactive_ordnumber;
75           last;
76         }
77       }
78     }
79
80     foreach my $inv ( @invoices_to_print ) { $self->_print_invoice($inv); }
81     foreach my $inv ( @invoices_to_email ) { $self->_email_invoice($inv); }
82
83     $self->_send_summary_email;
84
85       1;
86     })) {
87       $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
88       return undef;
89     }
90
91     if (@{ $self->{job_errors} }) {
92       my $msg = join "\n", @{ $self->{job_errors} };
93       _log_msg("Errors: $msg");
94       die $msg;
95     }
96
97   return 1;
98 }
99
100 sub _log_msg {
101   my $message  = join('', 'SL::BackgroundJob::CreatePeriodicInvoices: ', @_);
102   $message    .= "\n" unless $message =~ m/\n$/;
103   $::lxdebug->message(LXDebug::DEBUG1(), $message);
104 }
105
106 sub _generate_time_period_variables {
107   my $config            = shift;
108   my $period_start_date = shift;
109   my $period_end_date   = $period_start_date->clone->add(months => $config->get_billing_period_length)->subtract(days => 1);
110
111   my @month_names       = ('',
112                            $::locale->text('January'), $::locale->text('February'), $::locale->text('March'),     $::locale->text('April'),   $::locale->text('May'),      $::locale->text('June'),
113                            $::locale->text('July'),    $::locale->text('August'),   $::locale->text('September'), $::locale->text('October'), $::locale->text('November'), $::locale->text('December'));
114
115   my $vars = {
116     current_quarter     => [ $period_start_date->clone->truncate(to => 'month'),                        sub { $_[0]->quarter } ],
117     previous_quarter    => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 3), sub { $_[0]->quarter } ],
118     next_quarter        => [ $period_start_date->clone->truncate(to => 'month')->add(     months => 3), sub { $_[0]->quarter } ],
119
120     current_month       => [ $period_start_date->clone->truncate(to => 'month'),                        sub { $_[0]->month } ],
121     previous_month      => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $_[0]->month } ],
122     next_month          => [ $period_start_date->clone->truncate(to => 'month')->add(     months => 1), sub { $_[0]->month } ],
123
124     current_month_long  => [ $period_start_date->clone->truncate(to => 'month'),                        sub { $month_names[ $_[0]->month ] } ],
125     previous_month_long => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $month_names[ $_[0]->month ] } ],
126     next_month_long     => [ $period_start_date->clone->truncate(to => 'month')->add(     months => 1), sub { $month_names[ $_[0]->month ] } ],
127
128     current_year        => [ $period_start_date->clone->truncate(to => 'year'),                         sub { $_[0]->year } ],
129     previous_year       => [ $period_start_date->clone->truncate(to => 'year')->subtract(years => 1),   sub { $_[0]->year } ],
130     next_year           => [ $period_start_date->clone->truncate(to => 'year')->add(     years => 1),   sub { $_[0]->year } ],
131
132     period_start_date   => [ $period_start_date->clone, sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
133     period_end_date     => [ $period_end_date,          sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
134   };
135
136   return $vars;
137 }
138
139 sub _replace_vars {
140   my (%params) = @_;
141   my $sub      = $params{attribute};
142   my $str      = $params{object}->$sub // '';
143   my $sub_fmt  = lc($params{attribute_format} // 'text');
144
145   my ($start_tag, $end_tag) = $sub_fmt eq 'html' ? ('&lt;%', '%&gt;') : ('<%', '%>');
146
147   $str =~ s{ ${start_tag} ([a-z0-9_]+) ( \s+ format \s*=\s* (.*?) \s* )? ${end_tag} }{
148     my ($key, $format) = ($1, $3);
149     $key               = $::locale->unquote_special_chars('html', $key) if $sub_fmt eq 'html';
150     my $new_value;
151
152     if ($params{vars}->{$key} && $format) {
153       $format    = $::locale->unquote_special_chars('html', $format) if $sub_fmt eq 'html';
154
155       $new_value = DateTime::Format::Strptime->new(
156         pattern     => $format,
157         locale      => 'de_DE',
158         time_zone   => 'local',
159       )->format_datetime($params{vars}->{$key}->[0]);
160
161     } elsif ($params{vars}->{$key}) {
162       $new_value = $params{vars}->{$1}->[1]->($params{vars}->{$1}->[0]);
163
164     } elsif ($params{invoice} && $params{invoice}->can($key)) {
165       $new_value = $params{invoice}->$key;
166     }
167
168     $new_value //= '';
169     $new_value   = $::locale->quote_special_chars('html', $new_value) if $sub_fmt eq 'html';
170
171     $new_value;
172
173   }eigx;
174
175   $params{object}->$sub($str);
176 }
177
178 sub _adjust_sellprices_for_period_lengths {
179   my (%params) = @_;
180
181   my $billing_len     = $params{config}->get_billing_period_length;
182   my $order_value_len = $params{config}->get_order_value_period_length;
183
184   return if $billing_len == $order_value_len;
185
186   my $is_last_invoice_in_cycle = $params{config}->is_last_bill_date_in_order_value_cycle(date => $params{period_start_date});
187
188   _log_msg("_adjust_sellprices_for_period_lengths: period_start_date $params{period_start_date} is_last_invoice_in_cycle $is_last_invoice_in_cycle billing_len $billing_len order_value_len $order_value_len");
189
190   if ($order_value_len < $billing_len) {
191     my $num_orders_per_invoice = $billing_len / $order_value_len;
192
193     $_->sellprice($_->sellprice * $num_orders_per_invoice) for @{ $params{invoice}->items };
194
195     return;
196   }
197
198   my $num_invoices_in_cycle = $order_value_len / $billing_len;
199
200   foreach my $item (@{ $params{invoice}->items }) {
201     my $sellprice_one_invoice = $::form->round_amount($item->sellprice * $billing_len / $order_value_len, 2);
202
203     if ($is_last_invoice_in_cycle) {
204       $item->sellprice($item->sellprice - ($num_invoices_in_cycle - 1) * $sellprice_one_invoice);
205
206     } else {
207       $item->sellprice($sellprice_one_invoice);
208     }
209   }
210 }
211
212 sub _create_periodic_invoice {
213   my $self              = shift;
214   my $config            = shift;
215   my $period_start_date = shift;
216
217   my $time_period_vars  = _generate_time_period_variables($config, $period_start_date);
218
219   my $invdate           = DateTime->today_local;
220
221   my $order   = $config->order;
222   my $invoice;
223   if (!$self->{db_obj}->db->with_transaction(sub {
224     1;                          # make Emacs happy
225
226     $invoice = SL::DB::Invoice->new_from($order);
227
228     my $tax_point = ($invoice->tax_point // $time_period_vars->{period_end_date}->[0])->clone;
229
230     while ($tax_point < $period_start_date) {
231       $tax_point->add(months => $config->get_billing_period_length);
232     }
233
234     my $intnotes  = $invoice->intnotes ? $invoice->intnotes . "\n\n" : '';
235     $intnotes    .= "Automatisch am " . $invdate->to_lxoffice . " erzeugte Rechnung";
236
237     $invoice->assign_attributes(deliverydate => $period_start_date,
238                                 tax_point    => $tax_point,
239                                 intnotes     => $intnotes,
240                                 employee     => $order->employee, # new_from sets employee to import user
241                                 direct_debit => $config->direct_debit,
242                                );
243
244     _replace_vars(object => $invoice, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'notes' ? 'html' : 'text')) for qw(notes intnotes transaction_description);
245
246     foreach my $item (@{ $invoice->items }) {
247       _replace_vars(object => $item, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'longdescription' ? 'html' : 'text')) for qw(description longdescription);
248     }
249
250     _adjust_sellprices_for_period_lengths(invoice => $invoice, config => $config, period_start_date => $period_start_date);
251
252     $invoice->post(ar_id => $config->ar_chart_id) || die;
253
254     $order->link_to_record($invoice);
255
256     foreach my $item (@{ $invoice->items }) {
257       foreach (qw(orderitems)) {    # expand if needed (delivery_order_items)
258           if ($item->{"converted_from_${_}_id"}) {
259             die unless $item->{id};
260             RecordLinks->create_links('mode'       => 'ids',
261                                       'from_table' => $_,
262                                       'from_ids'   => $item->{"converted_from_${_}_id"},
263                                       'to_table'   => 'invoice',
264                                       'to_id'      => $item->{id},
265             ) || die;
266             delete $item->{"converted_from_${_}_id"};
267          }
268       }
269     }
270
271     SL::DB::PeriodicInvoice->new(config_id         => $config->id,
272                                  ar_id             => $invoice->id,
273                                  period_start_date => $period_start_date)
274       ->save;
275
276     _log_msg("_create_invoice created for period start date $period_start_date id " . $invoice->id . " number " . $invoice->invnumber . " netamount " . $invoice->netamount . " amount " . $invoice->amount);
277
278     # die $invoice->transaction_description;
279
280     1;
281   })) {
282     $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
283     return undef;
284   }
285
286   return {
287     config            => $config,
288     period_start_date => $period_start_date,
289     invoice           => $invoice,
290     time_period_vars  => $time_period_vars,
291   };
292 }
293
294 sub _calculate_dates {
295   my ($config) = @_;
296   return $config->calculate_invoice_dates(end_date => DateTime->today_local);
297 }
298
299 sub _send_summary_email {
300   my ($self) = @_;
301   my %config = %::lx_office_conf;
302
303   return if !$config{periodic_invoices} || !$config{periodic_invoices}->{send_email_to} || !scalar @{ $self->{posted_invoices} };
304
305   return if $config{periodic_invoices}->{send_for_errors_only} && !@{ $self->{printed_failed} } && !@{ $self->{emailed_failed} };
306
307   my $email = $config{periodic_invoices}->{send_email_to};
308   if ($email !~ m{\@}) {
309     my $user = SL::DB::Manager::AuthUser->find_by(login => $email);
310     $email   = $user ? $user->get_config_value('email') : undef;
311   }
312
313   _log_msg("_send_summary_email: about to send to '" . ($email || '') . "'");
314
315   return unless $email;
316
317   my $template = Template->new({ 'INTERPOLATE' => 0,
318                                  'EVAL_PERL'   => 0,
319                                  'ABSOLUTE'    => 1,
320                                  'CACHE_SIZE'  => 0,
321                                });
322
323   return unless $template;
324
325   my $email_template = $config{periodic_invoices}->{email_template};
326   my $filename       = $email_template || ( (SL::DB::Default->get->templates || "templates/webpages") . "/oe/periodic_invoices_email.txt" );
327   my %params         = map { (uc($_) => $self->{$_}) } qw(posted_invoices printed_invoices printed_failed emailed_invoices emailed_failed disabled_orders);
328
329   my $output;
330   $template->process($filename, \%params, \$output) || die $template->error;
331
332   my $mail              = Mailer->new;
333   $mail->{from}         = $config{periodic_invoices}->{email_from};
334   $mail->{to}           = $email;
335   $mail->{subject}      = $config{periodic_invoices}->{email_subject};
336   $mail->{content_type} = $filename =~ m/.html$/ ? 'text/html' : 'text/plain';
337   $mail->{message}      = $output;
338
339   $mail->send;
340 }
341
342 sub _store_pdf_in_webdav {
343   my ($self, $pdf_file_name, $invoice) = @_;
344
345   return unless $::instance_conf->get_webdav_documents;
346
347   my $form = Form->new('');
348
349   $form->{cwd}              = SL::System::Process->exe_dir;
350   $form->{tmpdir}           = ($pdf_file_name =~ m{(.+)/})[0];
351   $form->{tmpfile}          = ($pdf_file_name =~ m{.+/(.+)})[0];
352   $form->{format}           = 'pdf';
353   $form->{formname}         = 'invoice';
354   $form->{type}             = 'invoice';
355   $form->{vc}               = 'customer';
356   $form->{invnumber}        = $invoice->invnumber;
357   $form->{recipient_locale} = $invoice->language ? $invoice->language->template_code : '';
358
359   Common::copy_file_to_webdav_folder($form);
360 }
361
362 sub _store_pdf_in_filemanagement {
363   my ($self, $pdf_file, $invoice) = @_;
364
365   return unless $::instance_conf->get_doc_storage;
366
367   # create a form for generate_attachment_filename
368   my $form = Form->new('');
369   $form->{invnumber} = $invoice->invnumber;
370   $form->{type}      = 'invoice';
371   $form->{format}    = 'pdf';
372   $form->{formname}  = 'invoice';
373   $form->{language}  = '_' . $invoice->language->template_code if $invoice->language;
374   my $doc_name       = $form->generate_attachment_filename();
375
376   SL::File->save(object_id   => $invoice->id,
377                  object_type => 'invoice',
378                  mime_type   => 'application/pdf',
379                  source      => 'created',
380                  file_type   => 'document',
381                  file_name   => $doc_name,
382                  file_path   => $pdf_file);
383 }
384
385 sub _print_invoice {
386   my ($self, $data) = @_;
387
388   my $invoice       = $data->{invoice};
389   my $config        = $data->{config};
390
391   return unless $config->print && $config->printer_id && $config->printer->printer_command;
392
393   my $form = Form->new;
394   $invoice->flatten_to_form($form, format_amounts => 1);
395
396   $form->{printer_code} = $config->printer->template_code;
397   $form->{copies}       = $config->copies;
398   $form->{formname}     = $form->{type};
399   $form->{format}       = 'pdf';
400   $form->{media}        = 'printer';
401   $form->{OUT}          = $config->printer->printer_command;
402   $form->{OUT_MODE}     = '|-';
403
404   $form->{TEMPLATE_DRIVER_OPTIONS} = { };
405   $form->{TEMPLATE_DRIVER_OPTIONS}->{variable_content_types} = $form->get_variable_content_types();
406
407   $form->prepare_for_printing;
408
409   $form->throw_on_error(sub {
410     eval {
411       $form->parse_template(\%::myconfig);
412       push @{ $self->{printed_invoices} }, $invoice;
413       1;
414     } or do {
415       push @{ $self->{job_errors} }, $EVAL_ERROR->error;
416       push @{ $self->{printed_failed} }, [ $invoice, $EVAL_ERROR->error ];
417     };
418   });
419 }
420
421 sub _email_invoice {
422   my ($self, $data) = @_;
423
424   $data->{config}->load;
425
426   return unless $data->{config}->send_email;
427
428   my @recipients =
429     uniq
430     map  { lc       }
431     grep { $_       }
432     map  { trim($_) }
433     (split(m{,}, $data->{config}->email_recipient_address),
434      $data->{config}->email_recipient_contact   ? ($data->{config}->email_recipient_contact->cp_email) : (),
435      $data->{invoice}->{customer}->invoice_mail ? ($data->{invoice}->{customer}->invoice_mail) : ()
436     );
437
438   return unless @recipients;
439
440   my $language      = $data->{invoice}->language ? $data->{invoice}->language->template_code : undef;
441   my %create_params = (
442     template               => scalar($self->find_template(name => 'invoice', language => $language)),
443     variables              => Form->new(''),
444     return                 => 'file_name',
445     record                 => $data->{invoice},
446     variable_content_types => {
447       longdescription => 'html',
448       partnotes       => 'html',
449       notes           => 'html',
450     },
451   );
452
453   $data->{invoice}->flatten_to_form($create_params{variables}, format_amounts => 1);
454   $create_params{variables}->prepare_for_printing;
455
456   my $pdf_file_name;
457   my $label = $language && Locale::is_supported($language) ? Locale->new($language)->text('Invoice') : $::locale->text('Invoice');
458
459   eval {
460     $pdf_file_name = $self->create_pdf(%create_params);
461
462     $self->_store_pdf_in_webdav        ($pdf_file_name, $data->{invoice});
463     $self->_store_pdf_in_filemanagement($pdf_file_name, $data->{invoice});
464
465     for (qw(email_subject email_body)) {
466       _replace_vars(
467         object           => $data->{config},
468         invoice          => $data->{invoice},
469         vars             => $data->{time_period_vars},
470         attribute        => $_,
471         attribute_format => 'text'
472       );
473     }
474
475     my $global_bcc = SL::DB::Default->get->global_bcc;
476     my $overall_error;
477
478     for my $recipient (@recipients) {
479       my $mail             = Mailer->new;
480       $mail->{record_id}   = $data->{invoice}->id,
481       $mail->{record_type} = 'invoice',
482       $mail->{from}        = $data->{config}->email_sender || $::lx_office_conf{periodic_invoices}->{email_from};
483       $mail->{to}          = $recipient;
484       $mail->{bcc}         = $global_bcc;
485       $mail->{subject}     = $data->{config}->email_subject;
486       $mail->{message}     = $data->{config}->email_body;
487       $mail->{attachments} = [{
488         path     => $pdf_file_name,
489         name     => sprintf('%s %s.pdf', $label, $data->{invoice}->invnumber),
490       }];
491
492       my $error        = $mail->send;
493
494       if ($error) {
495         push @{ $self->{job_errors} }, $error;
496         push @{ $self->{emailed_failed} }, [ $data->{invoice}, $error ];
497         $overall_error = 1;
498       }
499     }
500
501     push @{ $self->{emailed_invoices} }, $data->{invoice} unless $overall_error;
502
503     1;
504
505   } or do {
506     push @{ $self->{job_errors} }, $EVAL_ERROR;
507     push @{ $self->{emailed_failed} }, [ $data->{invoice}, $EVAL_ERROR ];
508   };
509
510   unlink $pdf_file_name if $pdf_file_name;
511 }
512
513 1;
514
515 __END__
516
517 =pod
518
519 =encoding utf8
520
521 =head1 NAME
522
523 SL::BackgroundJob::CleanBackgroundJobHistory - Create periodic
524 invoices for orders
525
526 =head1 SYNOPSIS
527
528 Iterate over all periodic invoice configurations, extend them if
529 applicable, calculate the dates for which invoices have to be posted
530 and post those invoices by converting the order into an invoice for
531 each date.
532
533 =head1 TOTO
534
535 =over 4
536
537 =item *
538
539 Strings like month names are hardcoded to German in this file.
540
541 =back
542
543 =head1 AUTHOR
544
545 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>
546
547 =cut