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->{job_errors} = [];
33 if (!$self->{db_obj}->db->with_transaction(sub {
36 my $configs = SL::DB::Manager::PeriodicInvoicesConfig->get_all(query => [ active => 1 ]);
38 foreach my $config (@{ $configs }) {
39 my $new_end_date = $config->handle_automatic_extension;
40 _log_msg("Periodic invoice configuration ID " . $config->id . " extended through " . $new_end_date->strftime('%d.%m.%Y') . "\n") if $new_end_date;
43 my (@new_invoices, @invoices_to_print, @invoices_to_email, @disabled_orders);
45 _log_msg("Number of configs: " . scalar(@{ $configs}));
47 foreach my $config (@{ $configs }) {
48 # A configuration can be set to inactive by
49 # $config->handle_automatic_extension. Therefore the check in
50 # ...->get_all() does not suffice.
51 _log_msg("Config " . $config->id . " active " . $config->active);
52 next unless $config->active;
54 my @dates = _calculate_dates($config);
56 _log_msg("Dates: " . join(' ', map { $_->to_lxoffice } @dates));
58 foreach my $date (@dates) {
59 my $data = $self->_create_periodic_invoice($config, $date);
62 _log_msg("Invoice " . $data->{invoice}->invnumber . " posted for config ID " . $config->id . ", period start date " . $::locale->format_date(\%::myconfig, $date) . "\n");
64 push @new_invoices, $data;
65 push @invoices_to_print, $data if $config->print;
66 push @invoices_to_email, $data if $config->send_email;
68 my $inactive_ordnumber = $config->disable_one_time_config;
69 if ($inactive_ordnumber) {
70 # disable one time configs and skip eventual invoices
71 _log_msg("Order " . $inactive_ordnumber . " deavtivated \n");
72 push @disabled_orders, $inactive_ordnumber;
78 foreach my $inv ( @invoices_to_print ) { $self->_print_invoice($inv); }
79 foreach my $inv ( @invoices_to_email ) { $self->_email_invoice($inv); }
81 $self->_send_summary_email(
82 [ map { $_->{invoice} } @new_invoices ],
83 [ map { $_->{invoice} } @invoices_to_print ],
84 [ map { $_->{invoice} } @invoices_to_email ],
90 $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
94 if (@{ $self->{job_errors} }) {
95 my $msg = join "\n", @{ $self->{job_errors} };
96 _log_msg("Errors: $msg");
104 my $message = join('', 'SL::BackgroundJob::CreatePeriodicInvoices: ', @_);
105 $message .= "\n" unless $message =~ m/\n$/;
106 $::lxdebug->message(LXDebug::DEBUG1(), $message);
109 sub _generate_time_period_variables {
111 my $period_start_date = shift;
112 my $period_end_date = $period_start_date->clone->add(months => $config->get_billing_period_length)->subtract(days => 1);
114 my @month_names = ('',
115 $::locale->text('January'), $::locale->text('February'), $::locale->text('March'), $::locale->text('April'), $::locale->text('May'), $::locale->text('June'),
116 $::locale->text('July'), $::locale->text('August'), $::locale->text('September'), $::locale->text('October'), $::locale->text('November'), $::locale->text('December'));
119 current_quarter => [ $period_start_date->clone->truncate(to => 'month'), sub { $_[0]->quarter } ],
120 previous_quarter => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 3), sub { $_[0]->quarter } ],
121 next_quarter => [ $period_start_date->clone->truncate(to => 'month')->add( months => 3), sub { $_[0]->quarter } ],
123 current_month => [ $period_start_date->clone->truncate(to => 'month'), sub { $_[0]->month } ],
124 previous_month => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $_[0]->month } ],
125 next_month => [ $period_start_date->clone->truncate(to => 'month')->add( months => 1), sub { $_[0]->month } ],
127 current_month_long => [ $period_start_date->clone->truncate(to => 'month'), sub { $month_names[ $_[0]->month ] } ],
128 previous_month_long => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $month_names[ $_[0]->month ] } ],
129 next_month_long => [ $period_start_date->clone->truncate(to => 'month')->add( months => 1), sub { $month_names[ $_[0]->month ] } ],
131 current_year => [ $period_start_date->clone->truncate(to => 'year'), sub { $_[0]->year } ],
132 previous_year => [ $period_start_date->clone->truncate(to => 'year')->subtract(years => 1), sub { $_[0]->year } ],
133 next_year => [ $period_start_date->clone->truncate(to => 'year')->add( years => 1), sub { $_[0]->year } ],
135 period_start_date => [ $period_start_date->clone, sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
136 period_end_date => [ $period_end_date, sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
144 my $sub = $params{attribute};
145 my $str = $params{object}->$sub // '';
146 my $sub_fmt = lc($params{attribute_format} // 'text');
148 my ($start_tag, $end_tag) = $sub_fmt eq 'html' ? ('<%', '%>') : ('<%', '%>');
150 $str =~ s{ ${start_tag} ([a-z0-9_]+) ( \s+ format \s*=\s* (.*?) \s* )? ${end_tag} }{
151 my ($key, $format) = ($1, $3);
152 $key = $::locale->unquote_special_chars('html', $key) if $sub_fmt eq 'html';
155 if ($params{vars}->{$key} && $format) {
156 $format = $::locale->unquote_special_chars('html', $format) if $sub_fmt eq 'html';
158 $new_value = DateTime::Format::Strptime->new(
161 time_zone => 'local',
162 )->format_datetime($params{vars}->{$key}->[0]);
164 } elsif ($params{vars}->{$key}) {
165 $new_value = $params{vars}->{$1}->[1]->($params{vars}->{$1}->[0]);
167 } elsif ($params{invoice} && $params{invoice}->can($key)) {
168 $new_value = $params{invoice}->$key;
172 $new_value = $::locale->quote_special_chars('html', $new_value) if $sub_fmt eq 'html';
178 $params{object}->$sub($str);
181 sub _adjust_sellprices_for_period_lengths {
184 my $billing_len = $params{config}->get_billing_period_length;
185 my $order_value_len = $params{config}->get_order_value_period_length;
187 return if $billing_len == $order_value_len;
189 my $is_last_invoice_in_cycle = $params{config}->is_last_bill_date_in_order_value_cycle(date => $params{period_start_date});
191 _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");
193 if ($order_value_len < $billing_len) {
194 my $num_orders_per_invoice = $billing_len / $order_value_len;
196 $_->sellprice($_->sellprice * $num_orders_per_invoice) for @{ $params{invoice}->items };
201 my $num_invoices_in_cycle = $order_value_len / $billing_len;
203 foreach my $item (@{ $params{invoice}->items }) {
204 my $sellprice_one_invoice = $::form->round_amount($item->sellprice * $billing_len / $order_value_len, 2);
206 if ($is_last_invoice_in_cycle) {
207 $item->sellprice($item->sellprice - ($num_invoices_in_cycle - 1) * $sellprice_one_invoice);
210 $item->sellprice($sellprice_one_invoice);
215 sub _create_periodic_invoice {
218 my $period_start_date = shift;
220 my $time_period_vars = _generate_time_period_variables($config, $period_start_date);
222 my $invdate = DateTime->today_local;
224 my $order = $config->order;
226 if (!$self->{db_obj}->db->with_transaction(sub {
227 1; # make Emacs happy
229 $invoice = SL::DB::Invoice->new_from($order);
231 my $intnotes = $invoice->intnotes ? $invoice->intnotes . "\n\n" : '';
232 $intnotes .= "Automatisch am " . $invdate->to_lxoffice . " erzeugte Rechnung";
234 $invoice->assign_attributes(deliverydate => $period_start_date,
235 intnotes => $intnotes,
236 employee => $order->employee, # new_from sets employee to import user
237 direct_debit => $config->direct_debit,
240 _replace_vars(object => $invoice, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'notes' ? 'html' : 'text')) for qw(notes intnotes transaction_description);
242 foreach my $item (@{ $invoice->items }) {
243 _replace_vars(object => $item, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'longdescription' ? 'html' : 'text')) for qw(description longdescription);
246 _adjust_sellprices_for_period_lengths(invoice => $invoice, config => $config, period_start_date => $period_start_date);
248 $invoice->post(ar_id => $config->ar_chart_id) || die;
250 $order->link_to_record($invoice);
252 foreach my $item (@{ $invoice->items }) {
253 foreach (qw(orderitems)) { # expand if needed (delivery_order_items)
254 if ($item->{"converted_from_${_}_id"}) {
255 die unless $item->{id};
256 RecordLinks->create_links('mode' => 'ids',
258 'from_ids' => $item->{"converted_from_${_}_id"},
259 'to_table' => 'invoice',
260 'to_id' => $item->{id},
262 delete $item->{"converted_from_${_}_id"};
267 SL::DB::PeriodicInvoice->new(config_id => $config->id,
268 ar_id => $invoice->id,
269 period_start_date => $period_start_date)
272 _log_msg("_create_invoice created for period start date $period_start_date id " . $invoice->id . " number " . $invoice->invnumber . " netamount " . $invoice->netamount . " amount " . $invoice->amount);
274 # die $invoice->transaction_description;
278 $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
284 period_start_date => $period_start_date,
286 time_period_vars => $time_period_vars,
290 sub _calculate_dates {
292 return $config->calculate_invoice_dates(end_date => DateTime->today_local);
295 sub _send_summary_email {
296 my ($self, $posted_invoices, $printed_invoices, $emailed_invoices,
297 $disabled_orders) = @_;
298 my %config = %::lx_office_conf;
300 return if !$config{periodic_invoices} || !$config{periodic_invoices}->{send_email_to} || !scalar @{ $posted_invoices };
302 my $user = SL::DB::Manager::AuthUser->find_by(login => $config{periodic_invoices}->{send_email_to});
303 my $email = $user ? $user->get_config_value('email') : undef;
305 return unless $email;
307 my $template = Template->new({ 'INTERPOLATE' => 0,
313 return unless $template;
315 my $email_template = $config{periodic_invoices}->{email_template};
316 my $filename = $email_template || ( (SL::DB::Default->get->templates || "templates/webpages") . "/oe/periodic_invoices_email.txt" );
317 my %params = ( POSTED_INVOICES => $posted_invoices,
318 PRINTED_INVOICES => $printed_invoices,
319 EMAILED_INVOICES => $emailed_invoices,
320 DISABLED_ORDERS => $disabled_orders );
323 $template->process($filename, \%params, \$output);
325 my $mail = Mailer->new;
326 $mail->{from} = $config{periodic_invoices}->{email_from};
327 $mail->{to} = $email;
328 $mail->{subject} = $config{periodic_invoices}->{email_subject};
329 $mail->{content_type} = $filename =~ m/.html$/ ? 'text/html' : 'text/plain';
330 $mail->{message} = $output;
335 sub _store_pdf_in_webdav {
336 my ($self, $pdf_file_name, $invoice) = @_;
338 return unless $::instance_conf->get_webdav_documents;
340 my $form = Form->new('');
342 $form->{cwd} = SL::System::Process->exe_dir;
343 $form->{tmpdir} = ($pdf_file_name =~ m{(.+)/})[0];
344 $form->{tmpfile} = ($pdf_file_name =~ m{.+/(.+)})[0];
345 $form->{format} = 'pdf';
346 $form->{formname} = 'invoice';
347 $form->{type} = 'invoice';
348 $form->{vc} = 'customer';
349 $form->{invnumber} = $invoice->invnumber;
350 $form->{recipient_locale} = $invoice->language ? $invoice->language->template_code : '';
352 Common::copy_file_to_webdav_folder($form);
356 my ($self, $data) = @_;
358 my $invoice = $data->{invoice};
359 my $config = $data->{config};
361 return unless $config->print && $config->printer_id && $config->printer->printer_command;
363 my $form = Form->new;
364 $invoice->flatten_to_form($form, format_amounts => 1);
366 $form->{printer_code} = $config->printer->template_code;
367 $form->{copies} = $config->copies;
368 $form->{formname} = $form->{type};
369 $form->{format} = 'pdf';
370 $form->{media} = 'printer';
371 $form->{OUT} = $config->printer->printer_command;
372 $form->{OUT_MODE} = '|-';
374 $form->{TEMPLATE_DRIVER_OPTIONS} = { };
375 $form->{TEMPLATE_DRIVER_OPTIONS}->{variable_content_types} = $form->get_variable_content_types();
377 $form->prepare_for_printing;
379 $form->throw_on_error(sub {
381 $form->parse_template(\%::myconfig);
384 push @{ $self->{job_errors} }, $EVAL_ERROR->error;
390 my ($self, $data) = @_;
392 $data->{config}->load;
394 return unless $data->{config}->send_email;
401 (split(m{,}, $data->{config}->email_recipient_address),
402 $data->{config}->email_recipient_contact ? ($data->{config}->email_recipient_contact->cp_email) : (),
403 $data->{invoice}->{customer}->invoice_mail ? ($data->{invoice}->{customer}->invoice_mail) : ()
406 return unless @recipients;
408 my $language = $data->{invoice}->language ? $data->{invoice}->language->template_code : undef;
409 my %create_params = (
410 template => scalar($self->find_template(name => 'invoice', language => $language)),
411 variables => Form->new(''),
412 return => 'file_name',
413 record => $data->{invoice},
414 variable_content_types => {
415 longdescription => 'html',
421 $data->{invoice}->flatten_to_form($create_params{variables}, format_amounts => 1);
422 $create_params{variables}->prepare_for_printing;
425 my $label = $language && Locale::is_supported($language) ? Locale->new($language)->text('Invoice') : $::locale->text('Invoice');
428 $pdf_file_name = $self->create_pdf(%create_params);
430 $self->_store_pdf_in_webdav($pdf_file_name, $data->{invoice});
432 for (qw(email_subject email_body)) {
434 object => $data->{config},
435 invoice => $data->{invoice},
436 vars => $data->{time_period_vars},
438 attribute_format => 'text'
442 my $global_bcc = SL::DB::Default->get->global_bcc;
444 for my $recipient (@recipients) {
445 my $mail = Mailer->new;
446 $mail->{record_id} = $data->{invoice}->id,
447 $mail->{record_type} = 'invoice',
448 $mail->{from} = $data->{config}->email_sender || $::lx_office_conf{periodic_invoices}->{email_from};
449 $mail->{to} = $recipient;
450 $mail->{bcc} = $global_bcc;
451 $mail->{subject} = $data->{config}->email_subject;
452 $mail->{message} = $data->{config}->email_body;
453 $mail->{attachments} = [{
454 path => $pdf_file_name,
455 name => sprintf('%s %s.pdf', $label, $data->{invoice}->invnumber),
458 my $error = $mail->send;
460 push @{ $self->{job_errors} }, $error if $error;
466 push @{ $self->{job_errors} }, $EVAL_ERROR;
469 unlink $pdf_file_name if $pdf_file_name;
482 SL::BackgroundJob::CleanBackgroundJobHistory - Create periodic
487 Iterate over all periodic invoice configurations, extend them if
488 applicable, calculate the dates for which invoices have to be posted
489 and post those invoices by converting the order into an invoice for
498 Strings like month names are hardcoded to German in this file.
504 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>