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, @disabled_orders);
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;
66 # disable one time configs (periodicity is only one time).
67 my $inactive_ordnumber = $config->disable_one_time_config;
68 push @disabled_orders, $inactive_ordnumber if $inactive_ordnumber;
71 foreach my $inv ( @invoices_to_print ) { $self->_print_invoice($inv); }
72 foreach my $inv ( @invoices_to_email ) { $self->_email_invoice($inv); }
74 $self->_send_summary_email(
75 [ map { $_->{invoice} } @new_invoices ],
76 [ map { $_->{invoice} } @invoices_to_print ],
77 [ map { $_->{invoice} } @invoices_to_email ],
81 if (@{ $self->{job_errors} }) {
82 my $msg = join "\n", @{ $self->{job_errors} };
83 _log_msg("Errors: $msg");
91 my $message = join('', 'SL::BackgroundJob::CreatePeriodicInvoices: ', @_);
92 $message .= "\n" unless $message =~ m/\n$/;
93 $::lxdebug->message(LXDebug::DEBUG1(), $message);
96 sub _generate_time_period_variables {
98 my $period_start_date = shift;
99 my $period_end_date = $period_start_date->clone->add(months => $config->get_billing_period_length)->subtract(days => 1);
101 my @month_names = ('',
102 $::locale->text('January'), $::locale->text('February'), $::locale->text('March'), $::locale->text('April'), $::locale->text('May'), $::locale->text('June'),
103 $::locale->text('July'), $::locale->text('August'), $::locale->text('September'), $::locale->text('October'), $::locale->text('November'), $::locale->text('December'));
106 current_quarter => [ $period_start_date->clone->truncate(to => 'month'), sub { $_[0]->quarter } ],
107 previous_quarter => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 3), sub { $_[0]->quarter } ],
108 next_quarter => [ $period_start_date->clone->truncate(to => 'month')->add( months => 3), sub { $_[0]->quarter } ],
110 current_month => [ $period_start_date->clone->truncate(to => 'month'), sub { $_[0]->month } ],
111 previous_month => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $_[0]->month } ],
112 next_month => [ $period_start_date->clone->truncate(to => 'month')->add( months => 1), sub { $_[0]->month } ],
114 current_month_long => [ $period_start_date->clone->truncate(to => 'month'), sub { $month_names[ $_[0]->month ] } ],
115 previous_month_long => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $month_names[ $_[0]->month ] } ],
116 next_month_long => [ $period_start_date->clone->truncate(to => 'month')->add( months => 1), sub { $month_names[ $_[0]->month ] } ],
118 current_year => [ $period_start_date->clone->truncate(to => 'year'), sub { $_[0]->year } ],
119 previous_year => [ $period_start_date->clone->truncate(to => 'year')->subtract(years => 1), sub { $_[0]->year } ],
120 next_year => [ $period_start_date->clone->truncate(to => 'year')->add( years => 1), sub { $_[0]->year } ],
122 period_start_date => [ $period_start_date->clone, sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
123 period_end_date => [ $period_end_date, sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
131 my $sub = $params{attribute};
132 my $str = $params{object}->$sub // '';
133 my $sub_fmt = lc($params{attribute_format} // 'text');
135 my ($start_tag, $end_tag) = $sub_fmt eq 'html' ? ('<%', '%>') : ('<%', '%>');
137 $str =~ s{ ${start_tag} ([a-z0-9_]+) ( \s+ format \s*=\s* (.*?) \s* )? ${end_tag} }{
138 my ($key, $format) = ($1, $3);
139 $key = $::locale->unquote_special_chars('html', $key) if $sub_fmt eq 'html';
142 if (!$params{vars}->{$key}) {
146 $format = $::locale->unquote_special_chars('html', $format) if $sub_fmt eq 'html';
148 $new_value = DateTime::Format::Strptime->new(
151 time_zone => 'local',
152 )->format_datetime($params{vars}->{$key}->[0]);
155 $new_value = $params{vars}->{$1}->[1]->($params{vars}->{$1}->[0]);
158 $new_value = $::locale->quote_special_chars('html', $new_value) if $sub_fmt eq 'html';
164 $params{object}->$sub($str);
167 sub _adjust_sellprices_for_period_lengths {
170 my $billing_len = $params{config}->get_billing_period_length;
171 my $order_value_len = $params{config}->get_order_value_period_length;
173 return if $billing_len == $order_value_len;
175 my $is_last_invoice_in_cycle = $params{config}->is_last_bill_date_in_order_value_cycle(date => $params{period_start_date});
177 _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");
179 if ($order_value_len < $billing_len) {
180 my $num_orders_per_invoice = $billing_len / $order_value_len;
182 $_->sellprice($_->sellprice * $num_orders_per_invoice) for @{ $params{invoice}->items };
187 my $num_invoices_in_cycle = $order_value_len / $billing_len;
189 foreach my $item (@{ $params{invoice}->items }) {
190 my $sellprice_one_invoice = $::form->round_amount($item->sellprice * $billing_len / $order_value_len, 2);
192 if ($is_last_invoice_in_cycle) {
193 $item->sellprice($item->sellprice - ($num_invoices_in_cycle - 1) * $sellprice_one_invoice);
196 $item->sellprice($sellprice_one_invoice);
201 sub _create_periodic_invoice {
204 my $period_start_date = shift;
206 my $time_period_vars = _generate_time_period_variables($config, $period_start_date);
208 my $invdate = DateTime->today_local;
210 my $order = $config->order;
212 if (!$self->{db_obj}->db->with_transaction(sub {
213 1; # make Emacs happy
215 $invoice = SL::DB::Invoice->new_from($order);
217 my $intnotes = $invoice->intnotes ? $invoice->intnotes . "\n\n" : '';
218 $intnotes .= "Automatisch am " . $invdate->to_lxoffice . " erzeugte Rechnung";
220 $invoice->assign_attributes(deliverydate => $period_start_date,
221 intnotes => $intnotes,
222 employee => $order->employee, # new_from sets employee to import user
223 direct_debit => $config->direct_debit,
226 _replace_vars(object => $invoice, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'notes' ? 'html' : 'text')) for qw(notes intnotes transaction_description);
228 foreach my $item (@{ $invoice->items }) {
229 _replace_vars(object => $item, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'longdescription' ? 'html' : 'text')) for qw(description longdescription);
232 _adjust_sellprices_for_period_lengths(invoice => $invoice, config => $config, period_start_date => $period_start_date);
234 $invoice->post(ar_id => $config->ar_chart_id) || die;
236 $order->link_to_record($invoice);
238 foreach my $item (@{ $invoice->items }) {
239 foreach (qw(orderitems)) { # expand if needed (delivery_order_items)
240 if ($item->{"converted_from_${_}_id"}) {
241 die unless $item->{id};
242 RecordLinks->create_links('mode' => 'ids',
244 'from_ids' => $item->{"converted_from_${_}_id"},
245 'to_table' => 'invoice',
246 'to_id' => $item->{id},
248 delete $item->{"converted_from_${_}_id"};
253 SL::DB::PeriodicInvoice->new(config_id => $config->id,
254 ar_id => $invoice->id,
255 period_start_date => $period_start_date)
258 _log_msg("_create_invoice created for period start date $period_start_date id " . $invoice->id . " number " . $invoice->invnumber . " netamount " . $invoice->netamount . " amount " . $invoice->amount);
260 # die $invoice->transaction_description;
264 $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
270 period_start_date => $period_start_date,
272 time_period_vars => $time_period_vars,
276 sub _calculate_dates {
278 return $config->calculate_invoice_dates(end_date => DateTime->today_local);
281 sub _send_summary_email {
282 my ($self, $posted_invoices, $printed_invoices, $emailed_invoices,
283 $disabled_orders) = @_;
284 my %config = %::lx_office_conf;
286 return if !$config{periodic_invoices} || !$config{periodic_invoices}->{send_email_to} || !scalar @{ $posted_invoices };
288 my $user = SL::DB::Manager::AuthUser->find_by(login => $config{periodic_invoices}->{send_email_to});
289 my $email = $user ? $user->get_config_value('email') : undef;
291 return unless $email;
293 my $template = Template->new({ 'INTERPOLATE' => 0,
299 return unless $template;
301 my $email_template = $config{periodic_invoices}->{email_template};
302 my $filename = $email_template || ( (SL::DB::Default->get->templates || "templates/webpages") . "/oe/periodic_invoices_email.txt" );
303 my %params = ( POSTED_INVOICES => $posted_invoices,
304 PRINTED_INVOICES => $printed_invoices,
305 EMAILED_INVOICES => $emailed_invoices,
306 DISABLED_ORDERS => $disabled_orders );
309 $template->process($filename, \%params, \$output);
311 my $mail = Mailer->new;
312 $mail->{from} = $config{periodic_invoices}->{email_from};
313 $mail->{to} = $email;
314 $mail->{subject} = $config{periodic_invoices}->{email_subject};
315 $mail->{content_type} = $filename =~ m/.html$/ ? 'text/html' : 'text/plain';
316 $mail->{message} = $output;
322 my ($self, $data) = @_;
324 my $invoice = $data->{invoice};
325 my $config = $data->{config};
327 return unless $config->print && $config->printer_id && $config->printer->printer_command;
329 my $form = Form->new;
330 $invoice->flatten_to_form($form, format_amounts => 1);
332 $form->{printer_code} = $config->printer->template_code;
333 $form->{copies} = $config->copies;
334 $form->{formname} = $form->{type};
335 $form->{format} = 'pdf';
336 $form->{media} = 'printer';
337 $form->{OUT} = $config->printer->printer_command;
338 $form->{OUT_MODE} = '|-';
340 $form->{TEMPLATE_DRIVER_OPTIONS} = {
341 variable_content_types => {
342 longdescription => 'html',
348 $form->prepare_for_printing;
350 $form->throw_on_error(sub {
352 $form->parse_template(\%::myconfig);
355 push @{ $self->{job_errors} }, $EVAL_ERROR->getMessage;
361 my ($self, $data) = @_;
363 $data->{config}->load;
365 return unless $data->{config}->send_email;
372 (split(m{,}, $data->{config}->email_recipient_address),
373 $data->{config}->email_recipient_contact ? ($data->{config}->email_recipient_contact->cp_email) : ());
375 return unless @recipients;
377 my %create_params = (
378 template => $self->find_template(name => 'invoice'),
379 variables => Form->new(''),
380 return => 'file_name',
381 variable_content_types => {
382 longdescription => 'html',
388 $data->{invoice}->flatten_to_form($create_params{variables}, format_amounts => 1);
389 $create_params{variables}->prepare_for_printing;
394 $pdf_file_name = $self->create_pdf(%create_params);
396 for (qw(email_subject email_body)) {
398 object => $data->{config},
399 vars => $data->{time_period_vars},
401 attribute_format => 'text'
405 for my $recipient (@recipients) {
406 my $mail = Mailer->new;
407 $mail->{from} = $data->{config}->email_sender || $::lx_office_conf{periodic_invoices}->{email_from};
408 $mail->{to} = $recipient;
409 $mail->{subject} = $data->{config}->email_subject;
410 $mail->{message} = $data->{config}->email_body;
411 $mail->{attachments} = [{
412 filename => $pdf_file_name,
413 name => sprintf('%s %s.pdf', $::locale->text('Invoice'), $data->{invoice}->invnumber),
416 my $error = $mail->send;
418 push @{ $self->{job_errors} }, $error if $error;
424 push @{ $self->{job_errors} }, $EVAL_ERROR;
427 unlink $pdf_file_name if $pdf_file_name;
440 SL::BackgroundJob::CleanBackgroundJobHistory - Create periodic
445 Iterate over all periodic invoice configurations, extend them if
446 applicable, calculate the dates for which invoices have to be posted
447 and post those invoices by converting the order into an invoice for
456 Strings like month names are hardcoded to German in this file.
462 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>