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;
19 use SL::Helper::CreatePDF qw(create_pdf find_template);
21 use SL::Util qw(trim);
22 use SL::System::Process;
25 $_[0]->create_standard_job('0 3 1 * *'); # first day of month at 3:00 am
30 $self->{db_obj} = shift;
32 $self->{$_} = [] for qw(job_errors posted_invoices printed_invoices printed_failed emailed_invoices emailed_failed disabled_orders);
34 if (!$self->{db_obj}->db->with_transaction(sub {
37 my $configs = SL::DB::Manager::PeriodicInvoicesConfig->get_all(query => [ active => 1 ]);
39 foreach my $config (@{ $configs }) {
40 my $new_end_date = $config->handle_automatic_extension;
41 _log_msg("Periodic invoice configuration ID " . $config->id . " extended through " . $new_end_date->strftime('%d.%m.%Y') . "\n") if $new_end_date;
44 my (@invoices_to_print, @invoices_to_email);
46 _log_msg("Number of configs: " . scalar(@{ $configs}));
48 foreach my $config (@{ $configs }) {
49 # A configuration can be set to inactive by
50 # $config->handle_automatic_extension. Therefore the check in
51 # ...->get_all() does not suffice.
52 _log_msg("Config " . $config->id . " active " . $config->active);
53 next unless $config->active;
55 my @dates = _calculate_dates($config);
57 _log_msg("Dates: " . join(' ', map { $_->to_lxoffice } @dates));
59 foreach my $date (@dates) {
60 my $data = $self->_create_periodic_invoice($config, $date);
63 _log_msg("Invoice " . $data->{invoice}->invnumber . " posted for config ID " . $config->id . ", period start date " . $::locale->format_date(\%::myconfig, $date) . "\n");
65 push @{ $self->{posted_invoices} }, $data->{invoice};
66 push @invoices_to_print, $data if $config->print;
67 push @invoices_to_email, $data if $config->send_email;
69 my $inactive_ordnumber = $config->disable_one_time_config;
70 if ($inactive_ordnumber) {
71 # disable one time configs and skip eventual invoices
72 _log_msg("Order " . $inactive_ordnumber . " deavtivated \n");
73 push @{ $self->{disabled_orders} }, $inactive_ordnumber;
79 foreach my $inv ( @invoices_to_print ) { $self->_print_invoice($inv); }
80 foreach my $inv ( @invoices_to_email ) { $self->_email_invoice($inv); }
82 $self->_send_summary_email;
86 $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
90 if (@{ $self->{job_errors} }) {
91 my $msg = join "\n", @{ $self->{job_errors} };
92 _log_msg("Errors: $msg");
100 my $message = join('', 'SL::BackgroundJob::CreatePeriodicInvoices: ', @_);
101 $message .= "\n" unless $message =~ m/\n$/;
102 $::lxdebug->message(LXDebug::DEBUG1(), $message);
105 sub _generate_time_period_variables {
107 my $period_start_date = shift;
108 my $period_end_date = $period_start_date->clone->add(months => $config->get_billing_period_length)->subtract(days => 1);
110 my @month_names = ('',
111 $::locale->text('January'), $::locale->text('February'), $::locale->text('March'), $::locale->text('April'), $::locale->text('May'), $::locale->text('June'),
112 $::locale->text('July'), $::locale->text('August'), $::locale->text('September'), $::locale->text('October'), $::locale->text('November'), $::locale->text('December'));
115 current_quarter => [ $period_start_date->clone->truncate(to => 'month'), sub { $_[0]->quarter } ],
116 previous_quarter => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 3), sub { $_[0]->quarter } ],
117 next_quarter => [ $period_start_date->clone->truncate(to => 'month')->add( months => 3), sub { $_[0]->quarter } ],
119 current_month => [ $period_start_date->clone->truncate(to => 'month'), sub { $_[0]->month } ],
120 previous_month => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $_[0]->month } ],
121 next_month => [ $period_start_date->clone->truncate(to => 'month')->add( months => 1), sub { $_[0]->month } ],
123 current_month_long => [ $period_start_date->clone->truncate(to => 'month'), sub { $month_names[ $_[0]->month ] } ],
124 previous_month_long => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $month_names[ $_[0]->month ] } ],
125 next_month_long => [ $period_start_date->clone->truncate(to => 'month')->add( months => 1), sub { $month_names[ $_[0]->month ] } ],
127 current_year => [ $period_start_date->clone->truncate(to => 'year'), sub { $_[0]->year } ],
128 previous_year => [ $period_start_date->clone->truncate(to => 'year')->subtract(years => 1), sub { $_[0]->year } ],
129 next_year => [ $period_start_date->clone->truncate(to => 'year')->add( years => 1), sub { $_[0]->year } ],
131 period_start_date => [ $period_start_date->clone, sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
132 period_end_date => [ $period_end_date, sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
140 my $sub = $params{attribute};
141 my $str = $params{object}->$sub // '';
142 my $sub_fmt = lc($params{attribute_format} // 'text');
144 my ($start_tag, $end_tag) = $sub_fmt eq 'html' ? ('<%', '%>') : ('<%', '%>');
146 $str =~ s{ ${start_tag} ([a-z0-9_]+) ( \s+ format \s*=\s* (.*?) \s* )? ${end_tag} }{
147 my ($key, $format) = ($1, $3);
148 $key = $::locale->unquote_special_chars('html', $key) if $sub_fmt eq 'html';
151 if ($params{vars}->{$key} && $format) {
152 $format = $::locale->unquote_special_chars('html', $format) if $sub_fmt eq 'html';
154 $new_value = DateTime::Format::Strptime->new(
157 time_zone => 'local',
158 )->format_datetime($params{vars}->{$key}->[0]);
160 } elsif ($params{vars}->{$key}) {
161 $new_value = $params{vars}->{$1}->[1]->($params{vars}->{$1}->[0]);
163 } elsif ($params{invoice} && $params{invoice}->can($key)) {
164 $new_value = $params{invoice}->$key;
168 $new_value = $::locale->quote_special_chars('html', $new_value) if $sub_fmt eq 'html';
174 $params{object}->$sub($str);
177 sub _adjust_sellprices_for_period_lengths {
180 my $billing_len = $params{config}->get_billing_period_length;
181 my $order_value_len = $params{config}->get_order_value_period_length;
183 return if $billing_len == $order_value_len;
185 my $is_last_invoice_in_cycle = $params{config}->is_last_bill_date_in_order_value_cycle(date => $params{period_start_date});
187 _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 if ($order_value_len < $billing_len) {
190 my $num_orders_per_invoice = $billing_len / $order_value_len;
192 $_->sellprice($_->sellprice * $num_orders_per_invoice) for @{ $params{invoice}->items };
197 my $num_invoices_in_cycle = $order_value_len / $billing_len;
199 foreach my $item (@{ $params{invoice}->items }) {
200 my $sellprice_one_invoice = $::form->round_amount($item->sellprice * $billing_len / $order_value_len, 2);
202 if ($is_last_invoice_in_cycle) {
203 $item->sellprice($item->sellprice - ($num_invoices_in_cycle - 1) * $sellprice_one_invoice);
206 $item->sellprice($sellprice_one_invoice);
211 sub _create_periodic_invoice {
214 my $period_start_date = shift;
216 my $time_period_vars = _generate_time_period_variables($config, $period_start_date);
218 my $invdate = DateTime->today_local;
220 my $order = $config->order;
222 if (!$self->{db_obj}->db->with_transaction(sub {
223 1; # make Emacs happy
225 $invoice = SL::DB::Invoice->new_from($order);
227 my $tax_point = ($invoice->tax_point // $time_period_vars->{period_end_date}->[0])->clone;
229 while ($tax_point < $period_start_date) {
230 $tax_point->add(months => $config->get_billing_period_length);
233 my $intnotes = $invoice->intnotes ? $invoice->intnotes . "\n\n" : '';
234 $intnotes .= "Automatisch am " . $invdate->to_lxoffice . " erzeugte Rechnung";
236 $invoice->assign_attributes(deliverydate => $period_start_date,
237 tax_point => $tax_point,
238 intnotes => $intnotes,
239 employee => $order->employee, # new_from sets employee to import user
240 direct_debit => $config->direct_debit,
243 _replace_vars(object => $invoice, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'notes' ? 'html' : 'text')) for qw(notes intnotes transaction_description);
245 foreach my $item (@{ $invoice->items }) {
246 _replace_vars(object => $item, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'longdescription' ? 'html' : 'text')) for qw(description longdescription);
249 _adjust_sellprices_for_period_lengths(invoice => $invoice, config => $config, period_start_date => $period_start_date);
251 $invoice->post(ar_id => $config->ar_chart_id) || die;
253 $order->link_to_record($invoice);
255 foreach my $item (@{ $invoice->items }) {
256 foreach (qw(orderitems)) { # expand if needed (delivery_order_items)
257 if ($item->{"converted_from_${_}_id"}) {
258 die unless $item->{id};
259 RecordLinks->create_links('mode' => 'ids',
261 'from_ids' => $item->{"converted_from_${_}_id"},
262 'to_table' => 'invoice',
263 'to_id' => $item->{id},
265 delete $item->{"converted_from_${_}_id"};
270 SL::DB::PeriodicInvoice->new(config_id => $config->id,
271 ar_id => $invoice->id,
272 period_start_date => $period_start_date)
275 _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 # die $invoice->transaction_description;
281 $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
287 period_start_date => $period_start_date,
289 time_period_vars => $time_period_vars,
293 sub _calculate_dates {
295 return $config->calculate_invoice_dates(end_date => DateTime->today_local);
298 sub _send_summary_email {
300 my %config = %::lx_office_conf;
302 return if !$config{periodic_invoices} || !$config{periodic_invoices}->{send_email_to} || !scalar @{ $self->{posted_invoices} };
304 return if $config{periodic_invoices}->{send_for_errors_only} && !@{ $self->{printed_failed} } && !@{ $self->{emailed_failed} };
306 my $email = $config{periodic_invoices}->{send_email_to};
307 if ($email !~ m{\@}) {
308 my $user = SL::DB::Manager::AuthUser->find_by(login => $email);
309 $email = $user ? $user->get_config_value('email') : undef;
312 _log_msg("_send_summary_email: about to send to '" . ($email || '') . "'");
314 return unless $email;
316 my $template = Template->new({ 'INTERPOLATE' => 0,
322 return unless $template;
324 my $email_template = $config{periodic_invoices}->{email_template};
325 my $filename = $email_template || ( (SL::DB::Default->get->templates || "templates/webpages") . "/oe/periodic_invoices_email.txt" );
326 my %params = map { (uc($_) => $self->{$_}) } qw(posted_invoices printed_invoices printed_failed emailed_invoices emailed_failed disabled_orders);
329 $template->process($filename, \%params, \$output) || die $template->error;
331 my $mail = Mailer->new;
332 $mail->{from} = $config{periodic_invoices}->{email_from};
333 $mail->{to} = $email;
334 $mail->{subject} = $config{periodic_invoices}->{email_subject};
335 $mail->{content_type} = $filename =~ m/.html$/ ? 'text/html' : 'text/plain';
336 $mail->{message} = $output;
341 sub _store_pdf_in_webdav {
342 my ($self, $pdf_file_name, $invoice) = @_;
344 return unless $::instance_conf->get_webdav_documents;
346 my $form = Form->new('');
348 $form->{cwd} = SL::System::Process->exe_dir;
349 $form->{tmpdir} = ($pdf_file_name =~ m{(.+)/})[0];
350 $form->{tmpfile} = ($pdf_file_name =~ m{.+/(.+)})[0];
351 $form->{format} = 'pdf';
352 $form->{formname} = 'invoice';
353 $form->{type} = 'invoice';
354 $form->{vc} = 'customer';
355 $form->{invnumber} = $invoice->invnumber;
356 $form->{recipient_locale} = $invoice->language ? $invoice->language->template_code : '';
358 Common::copy_file_to_webdav_folder($form);
362 my ($self, $data) = @_;
364 my $invoice = $data->{invoice};
365 my $config = $data->{config};
367 return unless $config->print && $config->printer_id && $config->printer->printer_command;
369 my $form = Form->new;
370 $invoice->flatten_to_form($form, format_amounts => 1);
372 $form->{printer_code} = $config->printer->template_code;
373 $form->{copies} = $config->copies;
374 $form->{formname} = $form->{type};
375 $form->{format} = 'pdf';
376 $form->{media} = 'printer';
377 $form->{OUT} = $config->printer->printer_command;
378 $form->{OUT_MODE} = '|-';
380 $form->{TEMPLATE_DRIVER_OPTIONS} = { };
381 $form->{TEMPLATE_DRIVER_OPTIONS}->{variable_content_types} = $form->get_variable_content_types();
383 $form->prepare_for_printing;
385 $form->throw_on_error(sub {
387 $form->parse_template(\%::myconfig);
388 push @{ $self->{printed_invoices} }, $invoice;
391 push @{ $self->{job_errors} }, $EVAL_ERROR->error;
392 push @{ $self->{printed_failed} }, [ $invoice, $EVAL_ERROR->error ];
398 my ($self, $data) = @_;
400 $data->{config}->load;
402 return unless $data->{config}->send_email;
409 (split(m{,}, $data->{config}->email_recipient_address),
410 $data->{config}->email_recipient_contact ? ($data->{config}->email_recipient_contact->cp_email) : (),
411 $data->{invoice}->{customer}->invoice_mail ? ($data->{invoice}->{customer}->invoice_mail) : ()
414 return unless @recipients;
416 my $language = $data->{invoice}->language ? $data->{invoice}->language->template_code : undef;
417 my %create_params = (
418 template => scalar($self->find_template(name => 'invoice', language => $language)),
419 variables => Form->new(''),
420 return => 'file_name',
421 record => $data->{invoice},
422 variable_content_types => {
423 longdescription => 'html',
429 $data->{invoice}->flatten_to_form($create_params{variables}, format_amounts => 1);
430 $create_params{variables}->prepare_for_printing;
433 my $label = $language && Locale::is_supported($language) ? Locale->new($language)->text('Invoice') : $::locale->text('Invoice');
436 $pdf_file_name = $self->create_pdf(%create_params);
438 $self->_store_pdf_in_webdav($pdf_file_name, $data->{invoice});
440 for (qw(email_subject email_body)) {
442 object => $data->{config},
443 invoice => $data->{invoice},
444 vars => $data->{time_period_vars},
446 attribute_format => 'text'
450 my $global_bcc = SL::DB::Default->get->global_bcc;
453 for my $recipient (@recipients) {
454 my $mail = Mailer->new;
455 $mail->{record_id} = $data->{invoice}->id,
456 $mail->{record_type} = 'invoice',
457 $mail->{from} = $data->{config}->email_sender || $::lx_office_conf{periodic_invoices}->{email_from};
458 $mail->{to} = $recipient;
459 $mail->{bcc} = $global_bcc;
460 $mail->{subject} = $data->{config}->email_subject;
461 $mail->{message} = $data->{config}->email_body;
462 $mail->{attachments} = [{
463 path => $pdf_file_name,
464 name => sprintf('%s %s.pdf', $label, $data->{invoice}->invnumber),
467 my $error = $mail->send;
470 push @{ $self->{job_errors} }, $error;
471 push @{ $self->{emailed_failed} }, [ $data->{invoice}, $error ];
476 push @{ $self->{emailed_invoices} }, $data->{invoice} unless $overall_error;
481 push @{ $self->{job_errors} }, $EVAL_ERROR;
482 push @{ $self->{emailed_failed} }, [ $data->{invoice}, $EVAL_ERROR ];
485 unlink $pdf_file_name if $pdf_file_name;
498 SL::BackgroundJob::CleanBackgroundJobHistory - Create periodic
503 Iterate over all periodic invoice configurations, extend them if
504 applicable, calculate the dates for which invoices have to be posted
505 and post those invoices by converting the order into an invoice for
514 Strings like month names are hardcoded to German in this file.
520 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>