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 $intnotes = $invoice->intnotes ? $invoice->intnotes . "\n\n" : '';
228 $intnotes .= "Automatisch am " . $invdate->to_lxoffice . " erzeugte Rechnung";
230 $invoice->assign_attributes(deliverydate => $period_start_date,
231 intnotes => $intnotes,
232 employee => $order->employee, # new_from sets employee to import user
233 direct_debit => $config->direct_debit,
236 _replace_vars(object => $invoice, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'notes' ? 'html' : 'text')) for qw(notes intnotes transaction_description);
238 foreach my $item (@{ $invoice->items }) {
239 _replace_vars(object => $item, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'longdescription' ? 'html' : 'text')) for qw(description longdescription);
242 _adjust_sellprices_for_period_lengths(invoice => $invoice, config => $config, period_start_date => $period_start_date);
244 $invoice->post(ar_id => $config->ar_chart_id) || die;
246 $order->link_to_record($invoice);
248 foreach my $item (@{ $invoice->items }) {
249 foreach (qw(orderitems)) { # expand if needed (delivery_order_items)
250 if ($item->{"converted_from_${_}_id"}) {
251 die unless $item->{id};
252 RecordLinks->create_links('mode' => 'ids',
254 'from_ids' => $item->{"converted_from_${_}_id"},
255 'to_table' => 'invoice',
256 'to_id' => $item->{id},
258 delete $item->{"converted_from_${_}_id"};
263 SL::DB::PeriodicInvoice->new(config_id => $config->id,
264 ar_id => $invoice->id,
265 period_start_date => $period_start_date)
268 _log_msg("_create_invoice created for period start date $period_start_date id " . $invoice->id . " number " . $invoice->invnumber . " netamount " . $invoice->netamount . " amount " . $invoice->amount);
270 # die $invoice->transaction_description;
274 $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
280 period_start_date => $period_start_date,
282 time_period_vars => $time_period_vars,
286 sub _calculate_dates {
288 return $config->calculate_invoice_dates(end_date => DateTime->today_local);
291 sub _send_summary_email {
293 my %config = %::lx_office_conf;
295 return if !$config{periodic_invoices} || !$config{periodic_invoices}->{send_email_to} || !scalar @{ $self->{posted_invoices} };
297 return if $config{periodic_invoices}->{send_for_errors_only} && !@{ $self->{printed_failed} } && !@{ $self->{emailed_failed} };
299 my $email = $config{periodic_invoices}->{send_email_to};
300 if ($email !~ m{\@}) {
301 my $user = SL::DB::Manager::AuthUser->find_by(login => $email);
302 $email = $user ? $user->get_config_value('email') : undef;
305 _log_msg("_send_summary_email: about to send to '" . ($email || '') . "'");
307 return unless $email;
309 my $template = Template->new({ 'INTERPOLATE' => 0,
315 return unless $template;
317 my $email_template = $config{periodic_invoices}->{email_template};
318 my $filename = $email_template || ( (SL::DB::Default->get->templates || "templates/webpages") . "/oe/periodic_invoices_email.txt" );
319 my %params = map { (uc($_) => $self->{$_}) } qw(posted_invoices printed_invoices printed_failed emailed_invoices emailed_failed disabled_orders);
322 $template->process($filename, \%params, \$output) || die $template->error;
324 my $mail = Mailer->new;
325 $mail->{from} = $config{periodic_invoices}->{email_from};
326 $mail->{to} = $email;
327 $mail->{subject} = $config{periodic_invoices}->{email_subject};
328 $mail->{content_type} = $filename =~ m/.html$/ ? 'text/html' : 'text/plain';
329 $mail->{message} = $output;
334 sub _store_pdf_in_webdav {
335 my ($self, $pdf_file_name, $invoice) = @_;
337 return unless $::instance_conf->get_webdav_documents;
339 my $form = Form->new('');
341 $form->{cwd} = SL::System::Process->exe_dir;
342 $form->{tmpdir} = ($pdf_file_name =~ m{(.+)/})[0];
343 $form->{tmpfile} = ($pdf_file_name =~ m{.+/(.+)})[0];
344 $form->{format} = 'pdf';
345 $form->{formname} = 'invoice';
346 $form->{type} = 'invoice';
347 $form->{vc} = 'customer';
348 $form->{invnumber} = $invoice->invnumber;
349 $form->{recipient_locale} = $invoice->language ? $invoice->language->template_code : '';
351 Common::copy_file_to_webdav_folder($form);
355 my ($self, $data) = @_;
357 my $invoice = $data->{invoice};
358 my $config = $data->{config};
360 return unless $config->print && $config->printer_id && $config->printer->printer_command;
362 my $form = Form->new;
363 $invoice->flatten_to_form($form, format_amounts => 1);
365 $form->{printer_code} = $config->printer->template_code;
366 $form->{copies} = $config->copies;
367 $form->{formname} = $form->{type};
368 $form->{format} = 'pdf';
369 $form->{media} = 'printer';
370 $form->{OUT} = $config->printer->printer_command;
371 $form->{OUT_MODE} = '|-';
373 $form->{TEMPLATE_DRIVER_OPTIONS} = { };
374 $form->{TEMPLATE_DRIVER_OPTIONS}->{variable_content_types} = $form->get_variable_content_types();
376 $form->prepare_for_printing;
378 $form->throw_on_error(sub {
380 $form->parse_template(\%::myconfig);
381 push @{ $self->{printed_invoices} }, $invoice;
384 push @{ $self->{job_errors} }, $EVAL_ERROR->error;
385 push @{ $self->{printed_failed} }, [ $invoice, $EVAL_ERROR->error ];
391 my ($self, $data) = @_;
393 $data->{config}->load;
395 return unless $data->{config}->send_email;
402 (split(m{,}, $data->{config}->email_recipient_address),
403 $data->{config}->email_recipient_contact ? ($data->{config}->email_recipient_contact->cp_email) : (),
404 $data->{invoice}->{customer}->invoice_mail ? ($data->{invoice}->{customer}->invoice_mail) : ()
407 return unless @recipients;
409 my $language = $data->{invoice}->language ? $data->{invoice}->language->template_code : undef;
410 my %create_params = (
411 template => scalar($self->find_template(name => 'invoice', language => $language)),
412 variables => Form->new(''),
413 return => 'file_name',
414 record => $data->{invoice},
415 variable_content_types => {
416 longdescription => 'html',
422 $data->{invoice}->flatten_to_form($create_params{variables}, format_amounts => 1);
423 $create_params{variables}->prepare_for_printing;
426 my $label = $language && Locale::is_supported($language) ? Locale->new($language)->text('Invoice') : $::locale->text('Invoice');
429 $pdf_file_name = $self->create_pdf(%create_params);
431 $self->_store_pdf_in_webdav($pdf_file_name, $data->{invoice});
433 for (qw(email_subject email_body)) {
435 object => $data->{config},
436 invoice => $data->{invoice},
437 vars => $data->{time_period_vars},
439 attribute_format => 'text'
443 my $global_bcc = SL::DB::Default->get->global_bcc;
446 for my $recipient (@recipients) {
447 my $mail = Mailer->new;
448 $mail->{record_id} = $data->{invoice}->id,
449 $mail->{record_type} = 'invoice',
450 $mail->{from} = $data->{config}->email_sender || $::lx_office_conf{periodic_invoices}->{email_from};
451 $mail->{to} = $recipient;
452 $mail->{bcc} = $global_bcc;
453 $mail->{subject} = $data->{config}->email_subject;
454 $mail->{message} = $data->{config}->email_body;
455 $mail->{attachments} = [{
456 path => $pdf_file_name,
457 name => sprintf('%s %s.pdf', $label, $data->{invoice}->invnumber),
460 my $error = $mail->send;
463 push @{ $self->{job_errors} }, $error;
464 push @{ $self->{emailed_failed} }, [ $data->{invoice}, $error ];
469 push @{ $self->{emailed_invoices} }, $data->{invoice} unless $overall_error;
474 push @{ $self->{job_errors} }, $EVAL_ERROR;
475 push @{ $self->{emailed_failed} }, [ $data->{invoice}, $EVAL_ERROR ];
478 unlink $pdf_file_name if $pdf_file_name;
491 SL::BackgroundJob::CleanBackgroundJobHistory - Create periodic
496 Iterate over all periodic invoice configurations, extend them if
497 applicable, calculate the dates for which invoices have to be posted
498 and post those invoices by converting the order into an invoice for
507 Strings like month names are hardcoded to German in this file.
513 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>