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;
64 my $inactive_ordnumber = $config->disable_one_time_config;
65 if ($inactive_ordnumber) {
66 # disable one time configs and skip eventual invoices
67 _log_msg("Order " . $inactive_ordnumber . " deavtivated \n");
68 push @disabled_orders, $inactive_ordnumber;
74 foreach my $inv ( @invoices_to_print ) { $self->_print_invoice($inv); }
75 foreach my $inv ( @invoices_to_email ) { $self->_email_invoice($inv); }
77 $self->_send_summary_email(
78 [ map { $_->{invoice} } @new_invoices ],
79 [ map { $_->{invoice} } @invoices_to_print ],
80 [ map { $_->{invoice} } @invoices_to_email ],
84 if (@{ $self->{job_errors} }) {
85 my $msg = join "\n", @{ $self->{job_errors} };
86 _log_msg("Errors: $msg");
94 my $message = join('', 'SL::BackgroundJob::CreatePeriodicInvoices: ', @_);
95 $message .= "\n" unless $message =~ m/\n$/;
96 $::lxdebug->message(LXDebug::DEBUG1(), $message);
99 sub _generate_time_period_variables {
101 my $period_start_date = shift;
102 my $period_end_date = $period_start_date->clone->add(months => $config->get_billing_period_length)->subtract(days => 1);
104 my @month_names = ('',
105 $::locale->text('January'), $::locale->text('February'), $::locale->text('March'), $::locale->text('April'), $::locale->text('May'), $::locale->text('June'),
106 $::locale->text('July'), $::locale->text('August'), $::locale->text('September'), $::locale->text('October'), $::locale->text('November'), $::locale->text('December'));
109 current_quarter => [ $period_start_date->clone->truncate(to => 'month'), sub { $_[0]->quarter } ],
110 previous_quarter => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 3), sub { $_[0]->quarter } ],
111 next_quarter => [ $period_start_date->clone->truncate(to => 'month')->add( months => 3), sub { $_[0]->quarter } ],
113 current_month => [ $period_start_date->clone->truncate(to => 'month'), sub { $_[0]->month } ],
114 previous_month => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $_[0]->month } ],
115 next_month => [ $period_start_date->clone->truncate(to => 'month')->add( months => 1), sub { $_[0]->month } ],
117 current_month_long => [ $period_start_date->clone->truncate(to => 'month'), sub { $month_names[ $_[0]->month ] } ],
118 previous_month_long => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $month_names[ $_[0]->month ] } ],
119 next_month_long => [ $period_start_date->clone->truncate(to => 'month')->add( months => 1), sub { $month_names[ $_[0]->month ] } ],
121 current_year => [ $period_start_date->clone->truncate(to => 'year'), sub { $_[0]->year } ],
122 previous_year => [ $period_start_date->clone->truncate(to => 'year')->subtract(years => 1), sub { $_[0]->year } ],
123 next_year => [ $period_start_date->clone->truncate(to => 'year')->add( years => 1), sub { $_[0]->year } ],
125 period_start_date => [ $period_start_date->clone, sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
126 period_end_date => [ $period_end_date, sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
134 my $sub = $params{attribute};
135 my $str = $params{object}->$sub // '';
136 my $sub_fmt = lc($params{attribute_format} // 'text');
138 my ($start_tag, $end_tag) = $sub_fmt eq 'html' ? ('<%', '%>') : ('<%', '%>');
140 $str =~ s{ ${start_tag} ([a-z0-9_]+) ( \s+ format \s*=\s* (.*?) \s* )? ${end_tag} }{
141 my ($key, $format) = ($1, $3);
142 $key = $::locale->unquote_special_chars('html', $key) if $sub_fmt eq 'html';
145 if (!$params{vars}->{$key}) {
149 $format = $::locale->unquote_special_chars('html', $format) if $sub_fmt eq 'html';
151 $new_value = DateTime::Format::Strptime->new(
154 time_zone => 'local',
155 )->format_datetime($params{vars}->{$key}->[0]);
158 $new_value = $params{vars}->{$1}->[1]->($params{vars}->{$1}->[0]);
161 $new_value = $::locale->quote_special_chars('html', $new_value) if $sub_fmt eq 'html';
167 $params{object}->$sub($str);
170 sub _adjust_sellprices_for_period_lengths {
173 my $billing_len = $params{config}->get_billing_period_length;
174 my $order_value_len = $params{config}->get_order_value_period_length;
176 return if $billing_len == $order_value_len;
178 my $is_last_invoice_in_cycle = $params{config}->is_last_bill_date_in_order_value_cycle(date => $params{period_start_date});
180 _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");
182 if ($order_value_len < $billing_len) {
183 my $num_orders_per_invoice = $billing_len / $order_value_len;
185 $_->sellprice($_->sellprice * $num_orders_per_invoice) for @{ $params{invoice}->items };
190 my $num_invoices_in_cycle = $order_value_len / $billing_len;
192 foreach my $item (@{ $params{invoice}->items }) {
193 my $sellprice_one_invoice = $::form->round_amount($item->sellprice * $billing_len / $order_value_len, 2);
195 if ($is_last_invoice_in_cycle) {
196 $item->sellprice($item->sellprice - ($num_invoices_in_cycle - 1) * $sellprice_one_invoice);
199 $item->sellprice($sellprice_one_invoice);
204 sub _create_periodic_invoice {
207 my $period_start_date = shift;
209 my $time_period_vars = _generate_time_period_variables($config, $period_start_date);
211 my $invdate = DateTime->today_local;
213 my $order = $config->order;
215 if (!$self->{db_obj}->db->with_transaction(sub {
216 1; # make Emacs happy
218 $invoice = SL::DB::Invoice->new_from($order);
220 my $intnotes = $invoice->intnotes ? $invoice->intnotes . "\n\n" : '';
221 $intnotes .= "Automatisch am " . $invdate->to_lxoffice . " erzeugte Rechnung";
223 $invoice->assign_attributes(deliverydate => $period_start_date,
224 intnotes => $intnotes,
225 employee => $order->employee, # new_from sets employee to import user
226 direct_debit => $config->direct_debit,
229 _replace_vars(object => $invoice, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'notes' ? 'html' : 'text')) for qw(notes intnotes transaction_description);
231 foreach my $item (@{ $invoice->items }) {
232 _replace_vars(object => $item, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'longdescription' ? 'html' : 'text')) for qw(description longdescription);
235 _adjust_sellprices_for_period_lengths(invoice => $invoice, config => $config, period_start_date => $period_start_date);
237 $invoice->post(ar_id => $config->ar_chart_id) || die;
239 $order->link_to_record($invoice);
241 foreach my $item (@{ $invoice->items }) {
242 foreach (qw(orderitems)) { # expand if needed (delivery_order_items)
243 if ($item->{"converted_from_${_}_id"}) {
244 die unless $item->{id};
245 RecordLinks->create_links('mode' => 'ids',
247 'from_ids' => $item->{"converted_from_${_}_id"},
248 'to_table' => 'invoice',
249 'to_id' => $item->{id},
251 delete $item->{"converted_from_${_}_id"};
256 SL::DB::PeriodicInvoice->new(config_id => $config->id,
257 ar_id => $invoice->id,
258 period_start_date => $period_start_date)
261 _log_msg("_create_invoice created for period start date $period_start_date id " . $invoice->id . " number " . $invoice->invnumber . " netamount " . $invoice->netamount . " amount " . $invoice->amount);
263 # die $invoice->transaction_description;
267 $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
273 period_start_date => $period_start_date,
275 time_period_vars => $time_period_vars,
279 sub _calculate_dates {
281 return $config->calculate_invoice_dates(end_date => DateTime->today_local);
284 sub _send_summary_email {
285 my ($self, $posted_invoices, $printed_invoices, $emailed_invoices,
286 $disabled_orders) = @_;
287 my %config = %::lx_office_conf;
289 return if !$config{periodic_invoices} || !$config{periodic_invoices}->{send_email_to} || !scalar @{ $posted_invoices };
291 my $user = SL::DB::Manager::AuthUser->find_by(login => $config{periodic_invoices}->{send_email_to});
292 my $email = $user ? $user->get_config_value('email') : undef;
294 return unless $email;
296 my $template = Template->new({ 'INTERPOLATE' => 0,
302 return unless $template;
304 my $email_template = $config{periodic_invoices}->{email_template};
305 my $filename = $email_template || ( (SL::DB::Default->get->templates || "templates/webpages") . "/oe/periodic_invoices_email.txt" );
306 my %params = ( POSTED_INVOICES => $posted_invoices,
307 PRINTED_INVOICES => $printed_invoices,
308 EMAILED_INVOICES => $emailed_invoices,
309 DISABLED_ORDERS => $disabled_orders );
312 $template->process($filename, \%params, \$output);
314 my $mail = Mailer->new;
315 $mail->{from} = $config{periodic_invoices}->{email_from};
316 $mail->{to} = $email;
317 $mail->{subject} = $config{periodic_invoices}->{email_subject};
318 $mail->{content_type} = $filename =~ m/.html$/ ? 'text/html' : 'text/plain';
319 $mail->{message} = $output;
325 my ($self, $data) = @_;
327 my $invoice = $data->{invoice};
328 my $config = $data->{config};
330 return unless $config->print && $config->printer_id && $config->printer->printer_command;
332 my $form = Form->new;
333 $invoice->flatten_to_form($form, format_amounts => 1);
335 $form->{printer_code} = $config->printer->template_code;
336 $form->{copies} = $config->copies;
337 $form->{formname} = $form->{type};
338 $form->{format} = 'pdf';
339 $form->{media} = 'printer';
340 $form->{OUT} = $config->printer->printer_command;
341 $form->{OUT_MODE} = '|-';
343 $form->{TEMPLATE_DRIVER_OPTIONS} = { };
344 $form->{TEMPLATE_DRIVER_OPTIONS}->{variable_content_types} = $form->get_variable_content_types();
346 $form->prepare_for_printing;
348 $form->throw_on_error(sub {
350 $form->parse_template(\%::myconfig);
353 push @{ $self->{job_errors} }, $EVAL_ERROR->getMessage;
359 my ($self, $data) = @_;
361 $data->{config}->load;
363 return unless $data->{config}->send_email;
370 (split(m{,}, $data->{config}->email_recipient_address),
371 $data->{config}->email_recipient_contact ? ($data->{config}->email_recipient_contact->cp_email) : ());
373 return unless @recipients;
375 my %create_params = (
376 template => $self->find_template(name => 'invoice'),
377 variables => Form->new(''),
378 return => 'file_name',
379 variable_content_types => {
380 longdescription => 'html',
386 $data->{invoice}->flatten_to_form($create_params{variables}, format_amounts => 1);
387 $create_params{variables}->prepare_for_printing;
392 $pdf_file_name = $self->create_pdf(%create_params);
394 for (qw(email_subject email_body)) {
396 object => $data->{config},
397 vars => $data->{time_period_vars},
399 attribute_format => 'text'
403 my $global_bcc = SL::DB::Default->get->global_bcc;
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->{bcc} = $global_bcc;
410 $mail->{subject} = $data->{config}->email_subject;
411 $mail->{message} = $data->{config}->email_body;
412 $mail->{attachments} = [{
413 path => $pdf_file_name,
414 name => sprintf('%s %s.pdf', $::locale->text('Invoice'), $data->{invoice}->invnumber),
417 my $error = $mail->send;
419 push @{ $self->{job_errors} }, $error if $error;
425 push @{ $self->{job_errors} }, $EVAL_ERROR;
428 unlink $pdf_file_name if $pdf_file_name;
441 SL::BackgroundJob::CleanBackgroundJobHistory - Create periodic
446 Iterate over all periodic invoice configurations, extend them if
447 applicable, calculate the dates for which invoices have to be posted
448 and post those invoices by converting the order into an invoice for
457 Strings like month names are hardcoded to German in this file.
463 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>