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 $form->{TEMPLATE_DRIVER_OPTIONS}->{variable_content_types} = $form->get_variable_content_types();
343 $form->prepare_for_printing;
345 $form->throw_on_error(sub {
347 $form->parse_template(\%::myconfig);
350 push @{ $self->{job_errors} }, $EVAL_ERROR->getMessage;
356 my ($self, $data) = @_;
358 $data->{config}->load;
360 return unless $data->{config}->send_email;
367 (split(m{,}, $data->{config}->email_recipient_address),
368 $data->{config}->email_recipient_contact ? ($data->{config}->email_recipient_contact->cp_email) : ());
370 return unless @recipients;
372 my %create_params = (
373 template => $self->find_template(name => 'invoice'),
374 variables => Form->new(''),
375 return => 'file_name',
376 variable_content_types => {
377 longdescription => 'html',
383 $data->{invoice}->flatten_to_form($create_params{variables}, format_amounts => 1);
384 $create_params{variables}->prepare_for_printing;
389 $pdf_file_name = $self->create_pdf(%create_params);
391 for (qw(email_subject email_body)) {
393 object => $data->{config},
394 vars => $data->{time_period_vars},
396 attribute_format => 'text'
400 for my $recipient (@recipients) {
401 my $mail = Mailer->new;
402 $mail->{from} = $data->{config}->email_sender || $::lx_office_conf{periodic_invoices}->{email_from};
403 $mail->{to} = $recipient;
404 $mail->{subject} = $data->{config}->email_subject;
405 $mail->{message} = $data->{config}->email_body;
406 $mail->{attachments} = [{
407 filename => $pdf_file_name,
408 name => sprintf('%s %s.pdf', $::locale->text('Invoice'), $data->{invoice}->invnumber),
411 my $error = $mail->send;
413 push @{ $self->{job_errors} }, $error if $error;
419 push @{ $self->{job_errors} }, $EVAL_ERROR;
422 unlink $pdf_file_name if $pdf_file_name;
435 SL::BackgroundJob::CleanBackgroundJobHistory - Create periodic
440 Iterate over all periodic invoice configurations, extend them if
441 applicable, calculate the dates for which invoices have to be posted
442 and post those invoices by converting the order into an invoice for
451 Strings like month names are hardcoded to German in this file.
457 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>