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 # disalbe one timer only, if they are already processed
65 # disable one time configs (periodicity is only one time).
66 my $inactive_ordnumber = $config->disable_one_time_config;
67 push @disabled_orders, $inactive_ordnumber if $inactive_ordnumber;
73 foreach my $inv ( @invoices_to_print ) { $self->_print_invoice($inv); }
74 foreach my $inv ( @invoices_to_email ) { $self->_email_invoice($inv); }
76 $self->_send_summary_email(
77 [ map { $_->{invoice} } @new_invoices ],
78 [ map { $_->{invoice} } @invoices_to_print ],
79 [ map { $_->{invoice} } @invoices_to_email ],
83 if (@{ $self->{job_errors} }) {
84 my $msg = join "\n", @{ $self->{job_errors} };
85 _log_msg("Errors: $msg");
93 my $message = join('', 'SL::BackgroundJob::CreatePeriodicInvoices: ', @_);
94 $message .= "\n" unless $message =~ m/\n$/;
95 $::lxdebug->message(LXDebug::DEBUG1(), $message);
98 sub _generate_time_period_variables {
100 my $period_start_date = shift;
101 my $period_end_date = $period_start_date->clone->add(months => $config->get_billing_period_length)->subtract(days => 1);
103 my @month_names = ('',
104 $::locale->text('January'), $::locale->text('February'), $::locale->text('March'), $::locale->text('April'), $::locale->text('May'), $::locale->text('June'),
105 $::locale->text('July'), $::locale->text('August'), $::locale->text('September'), $::locale->text('October'), $::locale->text('November'), $::locale->text('December'));
108 current_quarter => [ $period_start_date->clone->truncate(to => 'month'), sub { $_[0]->quarter } ],
109 previous_quarter => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 3), sub { $_[0]->quarter } ],
110 next_quarter => [ $period_start_date->clone->truncate(to => 'month')->add( months => 3), sub { $_[0]->quarter } ],
112 current_month => [ $period_start_date->clone->truncate(to => 'month'), sub { $_[0]->month } ],
113 previous_month => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $_[0]->month } ],
114 next_month => [ $period_start_date->clone->truncate(to => 'month')->add( months => 1), sub { $_[0]->month } ],
116 current_month_long => [ $period_start_date->clone->truncate(to => 'month'), sub { $month_names[ $_[0]->month ] } ],
117 previous_month_long => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $month_names[ $_[0]->month ] } ],
118 next_month_long => [ $period_start_date->clone->truncate(to => 'month')->add( months => 1), sub { $month_names[ $_[0]->month ] } ],
120 current_year => [ $period_start_date->clone->truncate(to => 'year'), sub { $_[0]->year } ],
121 previous_year => [ $period_start_date->clone->truncate(to => 'year')->subtract(years => 1), sub { $_[0]->year } ],
122 next_year => [ $period_start_date->clone->truncate(to => 'year')->add( years => 1), sub { $_[0]->year } ],
124 period_start_date => [ $period_start_date->clone, sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
125 period_end_date => [ $period_end_date, sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
133 my $sub = $params{attribute};
134 my $str = $params{object}->$sub // '';
135 my $sub_fmt = lc($params{attribute_format} // 'text');
137 my ($start_tag, $end_tag) = $sub_fmt eq 'html' ? ('<%', '%>') : ('<%', '%>');
139 $str =~ s{ ${start_tag} ([a-z0-9_]+) ( \s+ format \s*=\s* (.*?) \s* )? ${end_tag} }{
140 my ($key, $format) = ($1, $3);
141 $key = $::locale->unquote_special_chars('html', $key) if $sub_fmt eq 'html';
144 if (!$params{vars}->{$key}) {
148 $format = $::locale->unquote_special_chars('html', $format) if $sub_fmt eq 'html';
150 $new_value = DateTime::Format::Strptime->new(
153 time_zone => 'local',
154 )->format_datetime($params{vars}->{$key}->[0]);
157 $new_value = $params{vars}->{$1}->[1]->($params{vars}->{$1}->[0]);
160 $new_value = $::locale->quote_special_chars('html', $new_value) if $sub_fmt eq 'html';
166 $params{object}->$sub($str);
169 sub _adjust_sellprices_for_period_lengths {
172 my $billing_len = $params{config}->get_billing_period_length;
173 my $order_value_len = $params{config}->get_order_value_period_length;
175 return if $billing_len == $order_value_len;
177 my $is_last_invoice_in_cycle = $params{config}->is_last_bill_date_in_order_value_cycle(date => $params{period_start_date});
179 _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");
181 if ($order_value_len < $billing_len) {
182 my $num_orders_per_invoice = $billing_len / $order_value_len;
184 $_->sellprice($_->sellprice * $num_orders_per_invoice) for @{ $params{invoice}->items };
189 my $num_invoices_in_cycle = $order_value_len / $billing_len;
191 foreach my $item (@{ $params{invoice}->items }) {
192 my $sellprice_one_invoice = $::form->round_amount($item->sellprice * $billing_len / $order_value_len, 2);
194 if ($is_last_invoice_in_cycle) {
195 $item->sellprice($item->sellprice - ($num_invoices_in_cycle - 1) * $sellprice_one_invoice);
198 $item->sellprice($sellprice_one_invoice);
203 sub _create_periodic_invoice {
206 my $period_start_date = shift;
208 my $time_period_vars = _generate_time_period_variables($config, $period_start_date);
210 my $invdate = DateTime->today_local;
212 my $order = $config->order;
214 if (!$self->{db_obj}->db->with_transaction(sub {
215 1; # make Emacs happy
217 $invoice = SL::DB::Invoice->new_from($order);
219 my $intnotes = $invoice->intnotes ? $invoice->intnotes . "\n\n" : '';
220 $intnotes .= "Automatisch am " . $invdate->to_lxoffice . " erzeugte Rechnung";
222 $invoice->assign_attributes(deliverydate => $period_start_date,
223 intnotes => $intnotes,
224 employee => $order->employee, # new_from sets employee to import user
225 direct_debit => $config->direct_debit,
228 _replace_vars(object => $invoice, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'notes' ? 'html' : 'text')) for qw(notes intnotes transaction_description);
230 foreach my $item (@{ $invoice->items }) {
231 _replace_vars(object => $item, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'longdescription' ? 'html' : 'text')) for qw(description longdescription);
234 _adjust_sellprices_for_period_lengths(invoice => $invoice, config => $config, period_start_date => $period_start_date);
236 $invoice->post(ar_id => $config->ar_chart_id) || die;
238 $order->link_to_record($invoice);
240 foreach my $item (@{ $invoice->items }) {
241 foreach (qw(orderitems)) { # expand if needed (delivery_order_items)
242 if ($item->{"converted_from_${_}_id"}) {
243 die unless $item->{id};
244 RecordLinks->create_links('mode' => 'ids',
246 'from_ids' => $item->{"converted_from_${_}_id"},
247 'to_table' => 'invoice',
248 'to_id' => $item->{id},
250 delete $item->{"converted_from_${_}_id"};
255 SL::DB::PeriodicInvoice->new(config_id => $config->id,
256 ar_id => $invoice->id,
257 period_start_date => $period_start_date)
260 _log_msg("_create_invoice created for period start date $period_start_date id " . $invoice->id . " number " . $invoice->invnumber . " netamount " . $invoice->netamount . " amount " . $invoice->amount);
262 # die $invoice->transaction_description;
266 $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
272 period_start_date => $period_start_date,
274 time_period_vars => $time_period_vars,
278 sub _calculate_dates {
280 return $config->calculate_invoice_dates(end_date => DateTime->today_local);
283 sub _send_summary_email {
284 my ($self, $posted_invoices, $printed_invoices, $emailed_invoices,
285 $disabled_orders) = @_;
286 my %config = %::lx_office_conf;
288 return if !$config{periodic_invoices} || !$config{periodic_invoices}->{send_email_to} || !scalar @{ $posted_invoices };
290 my $user = SL::DB::Manager::AuthUser->find_by(login => $config{periodic_invoices}->{send_email_to});
291 my $email = $user ? $user->get_config_value('email') : undef;
293 return unless $email;
295 my $template = Template->new({ 'INTERPOLATE' => 0,
301 return unless $template;
303 my $email_template = $config{periodic_invoices}->{email_template};
304 my $filename = $email_template || ( (SL::DB::Default->get->templates || "templates/webpages") . "/oe/periodic_invoices_email.txt" );
305 my %params = ( POSTED_INVOICES => $posted_invoices,
306 PRINTED_INVOICES => $printed_invoices,
307 EMAILED_INVOICES => $emailed_invoices,
308 DISABLED_ORDERS => $disabled_orders );
311 $template->process($filename, \%params, \$output);
313 my $mail = Mailer->new;
314 $mail->{from} = $config{periodic_invoices}->{email_from};
315 $mail->{to} = $email;
316 $mail->{subject} = $config{periodic_invoices}->{email_subject};
317 $mail->{content_type} = $filename =~ m/.html$/ ? 'text/html' : 'text/plain';
318 $mail->{message} = $output;
324 my ($self, $data) = @_;
326 my $invoice = $data->{invoice};
327 my $config = $data->{config};
329 return unless $config->print && $config->printer_id && $config->printer->printer_command;
331 my $form = Form->new;
332 $invoice->flatten_to_form($form, format_amounts => 1);
334 $form->{printer_code} = $config->printer->template_code;
335 $form->{copies} = $config->copies;
336 $form->{formname} = $form->{type};
337 $form->{format} = 'pdf';
338 $form->{media} = 'printer';
339 $form->{OUT} = $config->printer->printer_command;
340 $form->{OUT_MODE} = '|-';
342 $form->{TEMPLATE_DRIVER_OPTIONS} = { };
343 $form->{TEMPLATE_DRIVER_OPTIONS}->{variable_content_types} = $form->get_variable_content_types();
345 $form->prepare_for_printing;
347 $form->throw_on_error(sub {
349 $form->parse_template(\%::myconfig);
352 push @{ $self->{job_errors} }, $EVAL_ERROR->getMessage;
358 my ($self, $data) = @_;
360 $data->{config}->load;
362 return unless $data->{config}->send_email;
369 (split(m{,}, $data->{config}->email_recipient_address),
370 $data->{config}->email_recipient_contact ? ($data->{config}->email_recipient_contact->cp_email) : ());
372 return unless @recipients;
374 my %create_params = (
375 template => $self->find_template(name => 'invoice'),
376 variables => Form->new(''),
377 return => 'file_name',
378 variable_content_types => {
379 longdescription => 'html',
385 $data->{invoice}->flatten_to_form($create_params{variables}, format_amounts => 1);
386 $create_params{variables}->prepare_for_printing;
391 $pdf_file_name = $self->create_pdf(%create_params);
393 for (qw(email_subject email_body)) {
395 object => $data->{config},
396 vars => $data->{time_period_vars},
398 attribute_format => 'text'
402 for my $recipient (@recipients) {
403 my $mail = Mailer->new;
404 $mail->{from} = $data->{config}->email_sender || $::lx_office_conf{periodic_invoices}->{email_from};
405 $mail->{to} = $recipient;
406 $mail->{subject} = $data->{config}->email_subject;
407 $mail->{message} = $data->{config}->email_body;
408 $mail->{attachments} = [{
409 path => $pdf_file_name,
410 name => sprintf('%s %s.pdf', $::locale->text('Invoice'), $data->{invoice}->invnumber),
413 my $error = $mail->send;
415 push @{ $self->{job_errors} }, $error if $error;
421 push @{ $self->{job_errors} }, $EVAL_ERROR;
424 unlink $pdf_file_name if $pdf_file_name;
437 SL::BackgroundJob::CleanBackgroundJobHistory - Create periodic
442 Iterate over all periodic invoice configurations, extend them if
443 applicable, calculate the dates for which invoices have to be posted
444 and post those invoices by converting the order into an invoice for
453 Strings like month names are hardcoded to German in this file.
459 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>