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);
16 use SL::DB::PeriodicInvoice;
17 use SL::DB::PeriodicInvoicesConfig;
18 use SL::Helper::CreatePDF qw(create_pdf find_template);
20 use SL::Util qw(trim);
23 $_[0]->create_standard_job('0 3 1 * *'); # first day of month at 3:00 am
28 $self->{db_obj} = shift;
30 $self->{job_errors} = [];
32 my $configs = SL::DB::Manager::PeriodicInvoicesConfig->get_all(query => [ active => 1 ]);
34 foreach my $config (@{ $configs }) {
35 my $new_end_date = $config->handle_automatic_extension;
36 _log_msg("Periodic invoice configuration ID " . $config->id . " extended through " . $new_end_date->strftime('%d.%m.%Y') . "\n") if $new_end_date;
39 my (@new_invoices, @invoices_to_print, @invoices_to_email);
41 _log_msg("Number of configs: " . scalar(@{ $configs}));
43 foreach my $config (@{ $configs }) {
44 # A configuration can be set to inactive by
45 # $config->handle_automatic_extension. Therefore the check in
46 # ...->get_all() does not suffice.
47 _log_msg("Config " . $config->id . " active " . $config->active);
48 next unless $config->active;
50 my @dates = _calculate_dates($config);
52 _log_msg("Dates: " . join(' ', map { $_->to_lxoffice } @dates));
54 foreach my $date (@dates) {
55 my $data = $self->_create_periodic_invoice($config, $date);
58 _log_msg("Invoice " . $data->{invoice}->invnumber . " posted for config ID " . $config->id . ", period start date " . $::locale->format_date(\%::myconfig, $date) . "\n");
60 push @new_invoices, $data;
61 push @invoices_to_print, $data if $config->print;
62 push @invoices_to_email, $data if $config->send_email;
68 $self->_print_invoice($_) for @invoices_to_print;
69 $self->_email_invoice($_) for @invoices_to_email;
71 $self->_send_summary_email(
72 [ map { $_->{invoice} } @new_invoices ],
73 [ map { $_->{invoice} } @invoices_to_print ],
74 [ map { $_->{invoice} } @invoices_to_email ],
77 if (@{ $self->{job_errors} }) {
78 my $msg = join "\n", @{ $self->{job_errors} };
79 _log_msg("Errors: $msg");
87 my $message = join('', 'SL::BackgroundJob::CreatePeriodicInvoices: ', @_);
88 $message .= "\n" unless $message =~ m/\n$/;
89 $::lxdebug->message(LXDebug::DEBUG1(), $message);
92 sub _generate_time_period_variables {
94 my $period_start_date = shift;
95 my $period_end_date = $period_start_date->clone->add(months => $config->get_billing_period_length)->subtract(days => 1);
97 my @month_names = ('',
98 $::locale->text('January'), $::locale->text('February'), $::locale->text('March'), $::locale->text('April'), $::locale->text('May'), $::locale->text('June'),
99 $::locale->text('July'), $::locale->text('August'), $::locale->text('September'), $::locale->text('October'), $::locale->text('November'), $::locale->text('December'));
102 current_quarter => [ $period_start_date->clone->truncate(to => 'month'), sub { $_[0]->quarter } ],
103 previous_quarter => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 3), sub { $_[0]->quarter } ],
104 next_quarter => [ $period_start_date->clone->truncate(to => 'month')->add( months => 3), sub { $_[0]->quarter } ],
106 current_month => [ $period_start_date->clone->truncate(to => 'month'), sub { $_[0]->month } ],
107 previous_month => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $_[0]->month } ],
108 next_month => [ $period_start_date->clone->truncate(to => 'month')->add( months => 1), sub { $_[0]->month } ],
110 current_month_long => [ $period_start_date->clone->truncate(to => 'month'), sub { $month_names[ $_[0]->month ] } ],
111 previous_month_long => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $month_names[ $_[0]->month ] } ],
112 next_month_long => [ $period_start_date->clone->truncate(to => 'month')->add( months => 1), sub { $month_names[ $_[0]->month ] } ],
114 current_year => [ $period_start_date->clone->truncate(to => 'year'), sub { $_[0]->year } ],
115 previous_year => [ $period_start_date->clone->truncate(to => 'year')->subtract(years => 1), sub { $_[0]->year } ],
116 next_year => [ $period_start_date->clone->truncate(to => 'year')->add( years => 1), sub { $_[0]->year } ],
118 period_start_date => [ $period_start_date->clone, sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
119 period_end_date => [ $period_end_date, sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
127 my $sub = $params{attribute};
128 my $str = $params{object}->$sub // '';
129 my $sub_fmt = lc($params{attribute_format} // 'text');
131 my ($start_tag, $end_tag) = $sub_fmt eq 'html' ? ('<%', '%>') : ('<%', '%>');
133 $str =~ s{ ${start_tag} ([a-z0-9_]+) ( \s+ format \s*=\s* (.*?) \s* )? ${end_tag} }{
134 my ($key, $format) = ($1, $3);
135 $key = $::locale->unquote_special_chars('html', $key) if $sub_fmt eq 'html';
138 if (!$params{vars}->{$key}) {
142 $format = $::locale->unquote_special_chars('html', $format) if $sub_fmt eq 'html';
144 $new_value = DateTime::Format::Strptime->new(
147 time_zone => 'local',
148 )->format_datetime($params{vars}->{$key}->[0]);
151 $new_value = $params{vars}->{$1}->[1]->($params{vars}->{$1}->[0]);
154 $new_value = $::locale->quote_special_chars('html', $new_value) if $sub_fmt eq 'html';
160 $params{object}->$sub($str);
163 sub _adjust_sellprices_for_period_lengths {
166 my $billing_len = $params{config}->get_billing_period_length;
167 my $order_value_len = $params{config}->get_order_value_period_length;
169 return if $billing_len == $order_value_len;
171 my $is_last_invoice_in_cycle = $params{config}->is_last_bill_date_in_order_value_cycle(date => $params{period_start_date});
173 _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");
175 if ($order_value_len < $billing_len) {
176 my $num_orders_per_invoice = $billing_len / $order_value_len;
178 $_->sellprice($_->sellprice * $num_orders_per_invoice) for @{ $params{invoice}->items };
183 my $num_invoices_in_cycle = $order_value_len / $billing_len;
185 foreach my $item (@{ $params{invoice}->items }) {
186 my $sellprice_one_invoice = $::form->round_amount($item->sellprice * $billing_len / $order_value_len, 2);
188 if ($is_last_invoice_in_cycle) {
189 $item->sellprice($item->sellprice - ($num_invoices_in_cycle - 1) * $sellprice_one_invoice);
192 $item->sellprice($sellprice_one_invoice);
197 sub _create_periodic_invoice {
200 my $period_start_date = shift;
202 my $time_period_vars = _generate_time_period_variables($config, $period_start_date);
204 my $invdate = DateTime->today_local;
206 my $order = $config->order;
208 if (!$self->{db_obj}->db->do_transaction(sub {
209 1; # make Emacs happy
211 $invoice = SL::DB::Invoice->new_from($order);
213 my $intnotes = $invoice->intnotes ? $invoice->intnotes . "\n\n" : '';
214 $intnotes .= "Automatisch am " . $invdate->to_lxoffice . " erzeugte Rechnung";
216 $invoice->assign_attributes(deliverydate => $period_start_date,
217 intnotes => $intnotes,
218 employee => $order->employee, # new_from sets employee to import user
219 direct_debit => $config->direct_debit,
222 _replace_vars(object => $invoice, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'notes' ? 'html' : 'text')) for qw(notes intnotes transaction_description);
224 foreach my $item (@{ $invoice->items }) {
225 _replace_vars(object => $item, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'longdescription' ? 'html' : 'text')) for qw(description longdescription);
228 _adjust_sellprices_for_period_lengths(invoice => $invoice, config => $config, period_start_date => $period_start_date);
230 $invoice->post(ar_id => $config->ar_chart_id) || die;
232 $order->link_to_record($invoice);
234 foreach my $item (@{ $invoice->items }) {
235 foreach (qw(orderitems)) { # expand if needed (delivery_order_items)
236 if ($item->{"converted_from_${_}_id"}) {
237 die unless $item->{id};
238 RecordLinks->create_links('mode' => 'ids',
240 'from_ids' => $item->{"converted_from_${_}_id"},
241 'to_table' => 'invoice',
242 'to_id' => $item->{id},
244 delete $item->{"converted_from_${_}_id"};
249 SL::DB::PeriodicInvoice->new(config_id => $config->id,
250 ar_id => $invoice->id,
251 period_start_date => $period_start_date)
254 _log_msg("_create_invoice created for period start date $period_start_date id " . $invoice->id . " number " . $invoice->invnumber . " netamount " . $invoice->netamount . " amount " . $invoice->amount);
256 # die $invoice->transaction_description;
258 $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
264 period_start_date => $period_start_date,
266 time_period_vars => $time_period_vars,
270 sub _calculate_dates {
272 return $config->calculate_invoice_dates(end_date => DateTime->today_local);
275 sub _send_summary_email {
276 my ($self, $posted_invoices, $printed_invoices, $emailed_invoices) = @_;
278 my %config = %::lx_office_conf;
280 return if !$config{periodic_invoices} || !$config{periodic_invoices}->{send_email_to} || !scalar @{ $posted_invoices };
282 my $user = SL::DB::Manager::AuthUser->find_by(login => $config{periodic_invoices}->{send_email_to});
283 my $email = $user ? $user->get_config_value('email') : undef;
285 return unless $email;
287 my $template = Template->new({ 'INTERPOLATE' => 0,
293 return unless $template;
295 my $email_template = $config{periodic_invoices}->{email_template};
296 my $filename = $email_template || ( (SL::DB::Default->get->templates || "templates/webpages") . "/oe/periodic_invoices_email.txt" );
297 my %params = ( POSTED_INVOICES => $posted_invoices,
298 PRINTED_INVOICES => $printed_invoices,
299 EMAILED_INVOICES => $emailed_invoices );
302 $template->process($filename, \%params, \$output);
304 my $mail = Mailer->new;
305 $mail->{from} = $config{periodic_invoices}->{email_from};
306 $mail->{to} = $email;
307 $mail->{subject} = $config{periodic_invoices}->{email_subject};
308 $mail->{content_type} = $filename =~ m/.html$/ ? 'text/html' : 'text/plain';
309 $mail->{message} = $output;
315 my ($self, $data) = @_;
317 my $invoice = $data->{invoice};
318 my $config = $data->{config};
320 return unless $config->print && $config->printer_id && $config->printer->printer_command;
322 my $form = Form->new;
323 $invoice->flatten_to_form($form, format_amounts => 1);
325 $form->{printer_code} = $config->printer->template_code;
326 $form->{copies} = $config->copies;
327 $form->{formname} = $form->{type};
328 $form->{format} = 'pdf';
329 $form->{media} = 'printer';
330 $form->{OUT} = $config->printer->printer_command;
331 $form->{OUT_MODE} = '|-';
333 $form->{TEMPLATE_DRIVER_OPTIONS} = {
334 variable_content_types => {
335 longdescription => 'html',
341 $form->prepare_for_printing;
343 $form->throw_on_error(sub {
345 $form->parse_template(\%::myconfig);
348 push @{ $self->{job_errors} }, $EVAL_ERROR->getMessage;
354 my ($self, $data) = @_;
356 $data->{config}->load;
358 return unless $data->{config}->send_email;
365 (split(m{,}, $data->{config}->email_recipient_address),
366 $data->{config}->email_recipient_contact ? ($data->{config}->email_recipient_contact->cp_email) : ());
368 return unless @recipients;
370 my %create_params = (
371 template => $self->find_template(name => 'invoice'),
372 variables => Form->new(''),
373 return => 'file_name',
374 variable_content_types => {
375 longdescription => 'html',
381 $data->{invoice}->flatten_to_form($create_params{variables}, format_amounts => 1);
382 $create_params{variables}->prepare_for_printing;
387 $pdf_file_name = $self->create_pdf(%create_params);
389 for (qw(email_subject email_body)) {
391 object => $data->{config},
392 vars => $data->{time_period_vars},
394 attribute_format => 'text'
398 for my $recipient (@recipients) {
399 my $mail = Mailer->new;
400 $mail->{from} = $data->{config}->email_sender || $::lx_office_conf{periodic_invoices}->{email_from};
401 $mail->{to} = $recipient;
402 $mail->{subject} = $data->{config}->email_subject;
403 $mail->{message} = $data->{config}->email_body;
404 $mail->{attachments} = [{
405 filename => $pdf_file_name,
406 name => sprintf('%s %s.pdf', $::locale->text('Invoice'), $data->{invoice}->invnumber),
409 my $error = $mail->send;
411 push @{ $self->{job_errors} }, $error if $error;
417 push @{ $self->{job_errors} }, $EVAL_ERROR;
420 unlink $pdf_file_name if $pdf_file_name;
433 SL::BackgroundJob::CleanBackgroundJobHistory - Create periodic
438 Iterate over all periodic invoice configurations, extend them if
439 applicable, calculate the dates for which invoices have to be posted
440 and post those invoices by converting the order into an invoice for
449 Strings like month names are hardcoded to German in this file.
455 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>