1 package SL::BackgroundJob::CreatePeriodicInvoices;
5 use parent qw(SL::BackgroundJob::Base);
8 use DateTime::Format::Strptime;
9 use English qw(-no_match_vars);
10 use List::MoreUtils qw(uniq);
17 use SL::DB::PeriodicInvoice;
18 use SL::DB::PeriodicInvoicesConfig;
20 use SL::Helper::CreatePDF qw(create_pdf find_template);
22 use SL::Util qw(trim);
23 use SL::System::Process;
26 $_[0]->create_standard_job('0 3 1 * *'); # first day of month at 3:00 am
31 $self->{db_obj} = shift;
33 $self->{$_} = [] for qw(job_errors posted_invoices printed_invoices printed_failed emailed_invoices emailed_failed disabled_orders);
35 if (!$self->{db_obj}->db->with_transaction(sub {
38 my $configs = SL::DB::Manager::PeriodicInvoicesConfig->get_all(query => [ active => 1 ]);
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;
45 my (@invoices_to_print, @invoices_to_email);
47 _log_msg("Number of configs: " . scalar(@{ $configs}));
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;
56 my @dates = _calculate_dates($config);
58 _log_msg("Dates: " . join(' ', map { $_->to_lxoffice } @dates));
60 foreach my $date (@dates) {
61 my $data = $self->_create_periodic_invoice($config, $date);
64 _log_msg("Invoice " . $data->{invoice}->invnumber . " posted for config ID " . $config->id . ", period start date " . $::locale->format_date(\%::myconfig, $date) . "\n");
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;
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;
80 foreach my $inv ( @invoices_to_print ) { $self->_print_invoice($inv); }
81 foreach my $inv ( @invoices_to_email ) { $self->_email_invoice($inv); }
83 $self->_send_summary_email;
87 $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
91 if (@{ $self->{job_errors} }) {
92 my $msg = join "\n", @{ $self->{job_errors} };
93 _log_msg("Errors: $msg");
101 my $message = join('', 'SL::BackgroundJob::CreatePeriodicInvoices: ', @_);
102 $message .= "\n" unless $message =~ m/\n$/;
103 $::lxdebug->message(LXDebug::DEBUG1(), $message);
106 sub _generate_time_period_variables {
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);
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'));
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 } ],
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 } ],
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 ] } ],
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 } ],
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]) } ],
141 my $sub = $params{attribute};
142 my $str = $params{object}->$sub // '';
143 my $sub_fmt = lc($params{attribute_format} // 'text');
145 my ($start_tag, $end_tag) = $sub_fmt eq 'html' ? ('<%', '%>') : ('<%', '%>');
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';
152 if ($params{vars}->{$key} && $format) {
153 $format = $::locale->unquote_special_chars('html', $format) if $sub_fmt eq 'html';
155 $new_value = DateTime::Format::Strptime->new(
158 time_zone => 'local',
159 )->format_datetime($params{vars}->{$key}->[0]);
161 } elsif ($params{vars}->{$key}) {
162 $new_value = $params{vars}->{$1}->[1]->($params{vars}->{$1}->[0]);
164 } elsif ($params{invoice} && $params{invoice}->can($key)) {
165 $new_value = $params{invoice}->$key;
169 $new_value = $::locale->quote_special_chars('html', $new_value) if $sub_fmt eq 'html';
175 $params{object}->$sub($str);
178 sub _adjust_sellprices_for_period_lengths {
181 return if $params{config}->periodicity eq 'o';
183 my $billing_len = $params{config}->get_billing_period_length;
184 my $order_value_len = $params{config}->get_order_value_period_length;
186 return if $billing_len == $order_value_len;
188 my $is_last_invoice_in_cycle = $params{config}->is_last_bill_date_in_order_value_cycle(date => $params{period_start_date});
190 _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");
192 if ($order_value_len < $billing_len) {
193 my $num_orders_per_invoice = $billing_len / $order_value_len;
195 $_->sellprice($_->sellprice * $num_orders_per_invoice) for @{ $params{invoice}->items };
200 my $num_invoices_in_cycle = $order_value_len / $billing_len;
202 foreach my $item (@{ $params{invoice}->items }) {
203 my $sellprice_one_invoice = $::form->round_amount($item->sellprice * $billing_len / $order_value_len, 2);
205 if ($is_last_invoice_in_cycle) {
206 $item->sellprice($item->sellprice - ($num_invoices_in_cycle - 1) * $sellprice_one_invoice);
209 $item->sellprice($sellprice_one_invoice);
214 sub _create_periodic_invoice {
217 my $period_start_date = shift;
219 my $time_period_vars = _generate_time_period_variables($config, $period_start_date);
221 my $invdate = DateTime->today_local;
223 my $order = $config->order;
225 if (!$self->{db_obj}->db->with_transaction(sub {
226 1; # make Emacs happy
228 $invoice = SL::DB::Invoice->new_from($order);
230 my $tax_point = ($invoice->tax_point // $time_period_vars->{period_end_date}->[0])->clone;
232 while ($tax_point < $period_start_date) {
233 $tax_point->add(months => $config->get_billing_period_length);
236 my $intnotes = $invoice->intnotes ? $invoice->intnotes . "\n\n" : '';
237 $intnotes .= "Automatisch am " . $invdate->to_lxoffice . " erzeugte Rechnung";
239 $invoice->assign_attributes(deliverydate => $period_start_date,
240 tax_point => $tax_point,
241 intnotes => $intnotes,
242 employee => $order->employee, # new_from sets employee to import user
243 direct_debit => $config->direct_debit,
246 _replace_vars(object => $invoice, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'notes' ? 'html' : 'text')) for qw(notes intnotes transaction_description);
248 foreach my $item (@{ $invoice->items }) {
249 _replace_vars(object => $item, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'longdescription' ? 'html' : 'text')) for qw(description longdescription);
252 _adjust_sellprices_for_period_lengths(invoice => $invoice, config => $config, period_start_date => $period_start_date);
254 $invoice->post(ar_id => $config->ar_chart_id) || die;
256 $order->link_to_record($invoice);
258 foreach my $item (@{ $invoice->items }) {
259 foreach (qw(orderitems)) { # expand if needed (delivery_order_items)
260 if ($item->{"converted_from_${_}_id"}) {
261 die unless $item->{id};
262 RecordLinks->create_links('mode' => 'ids',
264 'from_ids' => $item->{"converted_from_${_}_id"},
265 'to_table' => 'invoice',
266 'to_id' => $item->{id},
268 delete $item->{"converted_from_${_}_id"};
273 SL::DB::PeriodicInvoice->new(config_id => $config->id,
274 ar_id => $invoice->id,
275 period_start_date => $period_start_date)
278 _log_msg("_create_invoice created for period start date $period_start_date id " . $invoice->id . " number " . $invoice->invnumber . " netamount " . $invoice->netamount . " amount " . $invoice->amount);
280 # die $invoice->transaction_description;
284 $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
290 period_start_date => $period_start_date,
292 time_period_vars => $time_period_vars,
296 sub _calculate_dates {
298 return $config->calculate_invoice_dates(end_date => DateTime->today_local);
301 sub _send_summary_email {
303 my %config = %::lx_office_conf;
305 return if !$config{periodic_invoices} || !$config{periodic_invoices}->{send_email_to} || !scalar @{ $self->{posted_invoices} };
307 return if $config{periodic_invoices}->{send_for_errors_only} && !@{ $self->{printed_failed} } && !@{ $self->{emailed_failed} };
309 my $email = $config{periodic_invoices}->{send_email_to};
310 if ($email !~ m{\@}) {
311 my $user = SL::DB::Manager::AuthUser->find_by(login => $email);
312 $email = $user ? $user->get_config_value('email') : undef;
315 _log_msg("_send_summary_email: about to send to '" . ($email || '') . "'");
317 return unless $email;
319 my $template = Template->new({ 'INTERPOLATE' => 0,
325 return unless $template;
327 my $email_template = $config{periodic_invoices}->{email_template};
328 my $filename = $email_template || ( (SL::DB::Default->get->templates || "templates/webpages") . "/oe/periodic_invoices_email.txt" );
329 my %params = map { (uc($_) => $self->{$_}) } qw(posted_invoices printed_invoices printed_failed emailed_invoices emailed_failed disabled_orders);
332 $template->process($filename, \%params, \$output) || die $template->error;
334 my $mail = Mailer->new;
335 $mail->{from} = $config{periodic_invoices}->{email_from};
336 $mail->{to} = $email;
337 $mail->{subject} = $config{periodic_invoices}->{email_subject};
338 $mail->{content_type} = $filename =~ m/.html$/ ? 'text/html' : 'text/plain';
339 $mail->{message} = $output;
344 sub _store_pdf_in_webdav {
345 my ($self, $pdf_file_name, $invoice) = @_;
347 return unless $::instance_conf->get_webdav_documents;
349 my $form = Form->new('');
351 $form->{cwd} = SL::System::Process->exe_dir;
352 $form->{tmpdir} = ($pdf_file_name =~ m{(.+)/})[0];
353 $form->{tmpfile} = ($pdf_file_name =~ m{.+/(.+)})[0];
354 $form->{format} = 'pdf';
355 $form->{formname} = 'invoice';
356 $form->{type} = 'invoice';
357 $form->{vc} = 'customer';
358 $form->{invnumber} = $invoice->invnumber;
359 $form->{recipient_locale} = $invoice->language ? $invoice->language->template_code : '';
361 Common::copy_file_to_webdav_folder($form);
364 sub _store_pdf_in_filemanagement {
365 my ($self, $pdf_file, $invoice) = @_;
367 return unless $::instance_conf->get_doc_storage;
369 # create a form for generate_attachment_filename
370 my $form = Form->new('');
371 $form->{invnumber} = $invoice->invnumber;
372 $form->{type} = 'invoice';
373 $form->{format} = 'pdf';
374 $form->{formname} = 'invoice';
375 $form->{language} = '_' . $invoice->language->template_code if $invoice->language;
376 my $doc_name = $form->generate_attachment_filename();
378 SL::File->save(object_id => $invoice->id,
379 object_type => 'invoice',
380 mime_type => 'application/pdf',
382 file_type => 'document',
383 file_name => $doc_name,
384 file_path => $pdf_file);
388 my ($self, $data) = @_;
390 my $invoice = $data->{invoice};
391 my $config = $data->{config};
393 return unless $config->print && $config->printer_id && $config->printer->printer_command;
395 my $form = Form->new;
396 $invoice->flatten_to_form($form, format_amounts => 1);
398 $form->{printer_code} = $config->printer->template_code;
399 $form->{copies} = $config->copies;
400 $form->{formname} = $form->{type};
401 $form->{format} = 'pdf';
402 $form->{media} = 'printer';
403 $form->{OUT} = $config->printer->printer_command;
404 $form->{OUT_MODE} = '|-';
406 $form->{TEMPLATE_DRIVER_OPTIONS} = { };
407 $form->{TEMPLATE_DRIVER_OPTIONS}->{variable_content_types} = $form->get_variable_content_types();
409 $form->prepare_for_printing;
411 $form->throw_on_error(sub {
413 $form->parse_template(\%::myconfig);
414 push @{ $self->{printed_invoices} }, $invoice;
417 push @{ $self->{job_errors} }, $EVAL_ERROR->error;
418 push @{ $self->{printed_failed} }, [ $invoice, $EVAL_ERROR->error ];
424 my ($self, $data) = @_;
426 $data->{config}->load;
428 return unless $data->{config}->send_email;
435 (split(m{,}, $data->{config}->email_recipient_address),
436 $data->{config}->email_recipient_contact ? ($data->{config}->email_recipient_contact->cp_email) : (),
437 $data->{invoice}->{customer}->invoice_mail ? ($data->{invoice}->{customer}->invoice_mail) : ()
440 return unless @recipients;
442 my $language = $data->{invoice}->language ? $data->{invoice}->language->template_code : undef;
443 my %create_params = (
444 template => scalar($self->find_template(name => 'invoice', language => $language)),
445 variables => Form->new(''),
446 return => 'file_name',
447 record => $data->{invoice},
448 variable_content_types => {
449 longdescription => 'html',
455 $data->{invoice}->flatten_to_form($create_params{variables}, format_amounts => 1);
456 $create_params{variables}->prepare_for_printing;
459 my $label = $language && Locale::is_supported($language) ? Locale->new($language)->text('Invoice') : $::locale->text('Invoice');
462 $pdf_file_name = $self->create_pdf(%create_params);
464 $self->_store_pdf_in_webdav ($pdf_file_name, $data->{invoice});
465 $self->_store_pdf_in_filemanagement($pdf_file_name, $data->{invoice});
467 for (qw(email_subject email_body)) {
469 object => $data->{config},
470 invoice => $data->{invoice},
471 vars => $data->{time_period_vars},
473 attribute_format => 'text'
477 my $global_bcc = SL::DB::Default->get->global_bcc;
480 for my $recipient (@recipients) {
481 my $mail = Mailer->new;
482 $mail->{record_id} = $data->{invoice}->id,
483 $mail->{record_type} = 'invoice',
484 $mail->{from} = $data->{config}->email_sender || $::lx_office_conf{periodic_invoices}->{email_from};
485 $mail->{to} = $recipient;
486 $mail->{bcc} = $global_bcc;
487 $mail->{subject} = $data->{config}->email_subject;
488 $mail->{message} = $data->{config}->email_body;
489 $mail->{attachments} = [{
490 path => $pdf_file_name,
491 name => sprintf('%s %s.pdf', $label, $data->{invoice}->invnumber),
494 my $error = $mail->send;
497 push @{ $self->{job_errors} }, $error;
498 push @{ $self->{emailed_failed} }, [ $data->{invoice}, $error ];
503 push @{ $self->{emailed_invoices} }, $data->{invoice} unless $overall_error;
508 push @{ $self->{job_errors} }, $EVAL_ERROR;
509 push @{ $self->{emailed_failed} }, [ $data->{invoice}, $EVAL_ERROR ];
512 unlink $pdf_file_name if $pdf_file_name;
525 SL::BackgroundJob::CleanBackgroundJobHistory - Create periodic
530 Iterate over all periodic invoice configurations, extend them if
531 applicable, calculate the dates for which invoices have to be posted
532 and post those invoices by converting the order into an invoice for
541 Strings like month names are hardcoded to German in this file.
547 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>