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}) {
159 $format = $::locale->unquote_special_chars('html', $format) if $sub_fmt eq 'html';
161 $new_value = DateTime::Format::Strptime->new(
164 time_zone => 'local',
165 )->format_datetime($params{vars}->{$key}->[0]);
168 $new_value = $params{vars}->{$1}->[1]->($params{vars}->{$1}->[0]);
171 $new_value = $::locale->quote_special_chars('html', $new_value) if $sub_fmt eq 'html';
177 $params{object}->$sub($str);
180 sub _adjust_sellprices_for_period_lengths {
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 $intnotes = $invoice->intnotes ? $invoice->intnotes . "\n\n" : '';
231 $intnotes .= "Automatisch am " . $invdate->to_lxoffice . " erzeugte Rechnung";
233 $invoice->assign_attributes(deliverydate => $period_start_date,
234 intnotes => $intnotes,
235 employee => $order->employee, # new_from sets employee to import user
236 direct_debit => $config->direct_debit,
239 _replace_vars(object => $invoice, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'notes' ? 'html' : 'text')) for qw(notes intnotes transaction_description);
241 foreach my $item (@{ $invoice->items }) {
242 _replace_vars(object => $item, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'longdescription' ? 'html' : 'text')) for qw(description longdescription);
245 _adjust_sellprices_for_period_lengths(invoice => $invoice, config => $config, period_start_date => $period_start_date);
247 $invoice->post(ar_id => $config->ar_chart_id) || die;
249 $order->link_to_record($invoice);
251 foreach my $item (@{ $invoice->items }) {
252 foreach (qw(orderitems)) { # expand if needed (delivery_order_items)
253 if ($item->{"converted_from_${_}_id"}) {
254 die unless $item->{id};
255 RecordLinks->create_links('mode' => 'ids',
257 'from_ids' => $item->{"converted_from_${_}_id"},
258 'to_table' => 'invoice',
259 'to_id' => $item->{id},
261 delete $item->{"converted_from_${_}_id"};
266 SL::DB::PeriodicInvoice->new(config_id => $config->id,
267 ar_id => $invoice->id,
268 period_start_date => $period_start_date)
271 _log_msg("_create_invoice created for period start date $period_start_date id " . $invoice->id . " number " . $invoice->invnumber . " netamount " . $invoice->netamount . " amount " . $invoice->amount);
273 # die $invoice->transaction_description;
277 $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
283 period_start_date => $period_start_date,
285 time_period_vars => $time_period_vars,
289 sub _calculate_dates {
291 return $config->calculate_invoice_dates(end_date => DateTime->today_local);
294 sub _send_summary_email {
295 my ($self, $posted_invoices, $printed_invoices, $emailed_invoices,
296 $disabled_orders) = @_;
297 my %config = %::lx_office_conf;
299 return if !$config{periodic_invoices} || !$config{periodic_invoices}->{send_email_to} || !scalar @{ $posted_invoices };
301 my $user = SL::DB::Manager::AuthUser->find_by(login => $config{periodic_invoices}->{send_email_to});
302 my $email = $user ? $user->get_config_value('email') : undef;
304 return unless $email;
306 my $template = Template->new({ 'INTERPOLATE' => 0,
312 return unless $template;
314 my $email_template = $config{periodic_invoices}->{email_template};
315 my $filename = $email_template || ( (SL::DB::Default->get->templates || "templates/webpages") . "/oe/periodic_invoices_email.txt" );
316 my %params = ( POSTED_INVOICES => $posted_invoices,
317 PRINTED_INVOICES => $printed_invoices,
318 EMAILED_INVOICES => $emailed_invoices,
319 DISABLED_ORDERS => $disabled_orders );
322 $template->process($filename, \%params, \$output);
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);
383 push @{ $self->{job_errors} }, $EVAL_ERROR->error;
389 my ($self, $data) = @_;
391 $data->{config}->load;
393 return unless $data->{config}->send_email;
400 (split(m{,}, $data->{config}->email_recipient_address),
401 $data->{config}->email_recipient_contact ? ($data->{config}->email_recipient_contact->cp_email) : (),
402 $data->{invoice}->{customer}->invoice_mail ? ($data->{invoice}->{customer}->invoice_mail) : ()
405 return unless @recipients;
407 my $language = $data->{invoice}->language ? $data->{invoice}->language->template_code : undef;
408 my %create_params = (
409 template => scalar($self->find_template(name => 'invoice', language => $language)),
410 variables => Form->new(''),
411 return => 'file_name',
412 record => $data->{invoice},
413 variable_content_types => {
414 longdescription => 'html',
420 $data->{invoice}->flatten_to_form($create_params{variables}, format_amounts => 1);
421 $create_params{variables}->prepare_for_printing;
424 my $label = $language && Locale::is_supported($language) ? Locale->new($language)->text('Invoice') : $::locale->text('Invoice');
427 $pdf_file_name = $self->create_pdf(%create_params);
429 $self->_store_pdf_in_webdav($pdf_file_name, $data->{invoice});
431 for (qw(email_subject email_body)) {
433 object => $data->{config},
434 vars => $data->{time_period_vars},
436 attribute_format => 'text'
440 my $global_bcc = SL::DB::Default->get->global_bcc;
442 for my $recipient (@recipients) {
443 my $mail = Mailer->new;
444 $mail->{record_id} = $data->{invoice}->id,
445 $mail->{record_type} = 'invoice',
446 $mail->{from} = $data->{config}->email_sender || $::lx_office_conf{periodic_invoices}->{email_from};
447 $mail->{to} = $recipient;
448 $mail->{bcc} = $global_bcc;
449 $mail->{subject} = $data->{config}->email_subject;
450 $mail->{message} = $data->{config}->email_body;
451 $mail->{attachments} = [{
452 path => $pdf_file_name,
453 name => sprintf('%s %s.pdf', $label, $data->{invoice}->invnumber),
456 my $error = $mail->send;
458 push @{ $self->{job_errors} }, $error if $error;
464 push @{ $self->{job_errors} }, $EVAL_ERROR;
467 unlink $pdf_file_name if $pdf_file_name;
480 SL::BackgroundJob::CleanBackgroundJobHistory - Create periodic
485 Iterate over all periodic invoice configurations, extend them if
486 applicable, calculate the dates for which invoices have to be posted
487 and post those invoices by converting the order into an invoice for
496 Strings like month names are hardcoded to German in this file.
502 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>