1 package SL::BackgroundJob::CreatePeriodicInvoices;
5 use parent qw(SL::BackgroundJob::Base);
8 use DateTime::Format::Strptime;
9 use English qw(-no_match_vars);
15 use SL::DB::PeriodicInvoice;
16 use SL::DB::PeriodicInvoicesConfig;
20 $_[0]->create_standard_job('0 3 1 * *'); # first day of month at 3:00 am
25 $self->{db_obj} = shift;
27 my $configs = SL::DB::Manager::PeriodicInvoicesConfig->get_all(query => [ active => 1 ]);
29 foreach my $config (@{ $configs }) {
30 my $new_end_date = $config->handle_automatic_extension;
31 _log_msg("Periodic invoice configuration ID " . $config->id . " extended through " . $new_end_date->strftime('%d.%m.%Y') . "\n") if $new_end_date;
34 my (@new_invoices, @invoices_to_print);
36 _log_msg("Number of configs: " . scalar(@{ $configs}));
38 foreach my $config (@{ $configs }) {
39 # A configuration can be set to inactive by
40 # $config->handle_automatic_extension. Therefore the check in
41 # ...->get_all() does not suffice.
42 _log_msg("Config " . $config->id . " active " . $config->active);
43 next unless $config->active;
45 my @dates = _calculate_dates($config);
47 _log_msg("Dates: " . join(' ', map { $_->to_lxoffice } @dates));
49 foreach my $date (@dates) {
50 my $invoice = $self->_create_periodic_invoice($config, $date);
53 _log_msg("Invoice " . $invoice->invnumber . " posted for config ID " . $config->id . ", period start date " . $::locale->format_date(\%::myconfig, $date) . "\n");
54 push @new_invoices, $invoice;
55 push @invoices_to_print, [ $invoice, $config ] if $config->print;
61 _print_invoice(@{ $_ }) for @invoices_to_print;
63 _send_email(\@new_invoices, [ map { $_->[0] } @invoices_to_print ]) if @new_invoices;
69 my $message = join('', 'SL::BackgroundJob::CreatePeriodicInvoices: ', @_);
70 $message .= "\n" unless $message =~ m/\n$/;
71 $::lxdebug->message(LXDebug::DEBUG1(), $message);
74 sub _generate_time_period_variables {
76 my $period_start_date = shift;
77 my $period_end_date = $period_start_date->clone->truncate(to => 'month')->add(months => $config->get_billing_period_length)->subtract(days => 1);
79 my @month_names = ('',
80 $::locale->text('January'), $::locale->text('February'), $::locale->text('March'), $::locale->text('April'), $::locale->text('May'), $::locale->text('June'),
81 $::locale->text('July'), $::locale->text('August'), $::locale->text('September'), $::locale->text('October'), $::locale->text('November'), $::locale->text('December'));
84 current_quarter => [ $period_start_date->clone->truncate(to => 'month'), sub { $_[0]->quarter } ],
85 previous_quarter => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 3), sub { $_[0]->quarter } ],
86 next_quarter => [ $period_start_date->clone->truncate(to => 'month')->add( months => 3), sub { $_[0]->quarter } ],
88 current_month => [ $period_start_date->clone->truncate(to => 'month'), sub { $_[0]->month } ],
89 previous_month => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $_[0]->month } ],
90 next_month => [ $period_start_date->clone->truncate(to => 'month')->add( months => 1), sub { $_[0]->month } ],
92 current_month_long => [ $period_start_date->clone->truncate(to => 'month'), sub { $month_names[ $_[0]->month ] } ],
93 previous_month_long => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $month_names[ $_[0]->month ] } ],
94 next_month_long => [ $period_start_date->clone->truncate(to => 'month')->add( months => 1), sub { $month_names[ $_[0]->month ] } ],
96 current_year => [ $period_start_date->clone->truncate(to => 'year'), sub { $_[0]->year } ],
97 previous_year => [ $period_start_date->clone->truncate(to => 'year')->subtract(years => 1), sub { $_[0]->year } ],
98 next_year => [ $period_start_date->clone->truncate(to => 'year')->add( years => 1), sub { $_[0]->year } ],
100 period_start_date => [ $period_start_date->clone->truncate(to => 'month'), sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
101 period_end_date => [ $period_end_date, sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
109 my $sub = $params{attribute};
110 my $str = $params{object}->$sub // '';
111 my $sub_fmt = lc($params{attribute_format} // 'text');
113 my ($start_tag, $end_tag) = $sub_fmt eq 'html' ? ('<%', '%>') : ('<%', '%>');
115 $str =~ s{ ${start_tag} ([a-z0-9_]+) ( \s+ format \s*=\s* (.*?) \s* )? ${end_tag} }{
116 my ($key, $format) = ($1, $3);
117 $key = $::locale->unquote_special_chars('html', $key) if $sub_fmt eq 'html';
120 if (!$params{vars}->{$key}) {
124 $format = $::locale->unquote_special_chars('html', $format) if $sub_fmt eq 'html';
126 $new_value = DateTime::Format::Strptime->new(
129 time_zone => 'local',
130 )->format_datetime($params{vars}->{$key}->[0]);
133 $new_value = $params{vars}->{$1}->[1]->($params{vars}->{$1}->[0]);
136 $new_value = $::locale->quote_special_chars('html', $new_value) if $sub_fmt eq 'html';
142 $params{object}->$sub($str);
145 sub _adjust_sellprices_for_period_lengths {
148 my $billing_len = $params{config}->get_billing_period_length;
149 my $order_value_len = $params{config}->get_order_value_period_length;
151 return if $billing_len == $order_value_len;
153 my $is_last_invoice_in_cycle = $params{config}->is_last_bill_date_in_order_value_cycle(date => $params{period_start_date});
155 _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");
157 if ($order_value_len < $billing_len) {
158 my $num_orders_per_invoice = $billing_len / $order_value_len;
160 $_->sellprice($_->sellprice * $num_orders_per_invoice) for @{ $params{invoice}->items };
165 my $num_invoices_in_cycle = $order_value_len / $billing_len;
167 foreach my $item (@{ $params{invoice}->items }) {
168 my $sellprice_one_invoice = $::form->round_amount($item->sellprice * $billing_len / $order_value_len, 2);
170 if ($is_last_invoice_in_cycle) {
171 $item->sellprice($item->sellprice - ($num_invoices_in_cycle - 1) * $sellprice_one_invoice);
174 $item->sellprice($sellprice_one_invoice);
179 sub _create_periodic_invoice {
180 $main::lxdebug->enter_sub();
184 my $period_start_date = shift;
186 my $time_period_vars = _generate_time_period_variables($config, $period_start_date);
188 my $invdate = DateTime->today_local;
190 my $order = $config->order;
192 if (!$self->{db_obj}->db->do_transaction(sub {
193 1; # make Emacs happy
195 $invoice = SL::DB::Invoice->new_from($order);
197 my $intnotes = $invoice->intnotes ? $invoice->intnotes . "\n\n" : '';
198 $intnotes .= "Automatisch am " . $invdate->to_lxoffice . " erzeugte Rechnung";
200 $invoice->assign_attributes(deliverydate => $period_start_date,
201 intnotes => $intnotes,
202 employee => $order->employee, # new_from sets employee to import user
205 _replace_vars(object => $invoice, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'notes' ? 'html' : 'text')) for qw(notes intnotes transaction_description);
207 foreach my $item (@{ $invoice->items }) {
208 _replace_vars(object => $item, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'longdescription' ? 'html' : 'text')) for qw(description longdescription);
211 _adjust_sellprices_for_period_lengths(invoice => $invoice, config => $config, period_start_date => $period_start_date);
213 $invoice->post(ar_id => $config->ar_chart_id) || die;
215 # like $form->add_shipto, but we don't need to check for a manual exception,
216 # because we can already assume this (otherwise no shipto_id from order)
217 if ($order->shipto_id) {
219 my $shipto_oe = SL::DB::Manager::Shipto->find_by(shipto_id => $order->shipto_id);
220 my $shipto_ar = $shipto_oe->clone_and_reset;
222 $shipto_ar->module('AR'); # alter module OE -> AR
223 $shipto_ar->trans_id($invoice->id); # alter trans_id -> new id from invoice
227 $order->link_to_record($invoice);
229 foreach my $item (@{ $invoice->items }) {
230 foreach (qw(orderitems)) { # expand if needed (delivery_order_items)
231 if ($item->{"converted_from_${_}_id"}) {
232 die unless $item->{id};
233 RecordLinks->create_links('mode' => 'ids',
235 'from_ids' => $item->{"converted_from_${_}_id"},
236 'to_table' => 'invoice',
237 'to_id' => $item->{id},
239 delete $item->{"converted_from_${_}_id"};
244 SL::DB::PeriodicInvoice->new(config_id => $config->id,
245 ar_id => $invoice->id,
246 period_start_date => $period_start_date)
249 _log_msg("_create_invoice created for period start date $period_start_date id " . $invoice->id . " number " . $invoice->invnumber . " netamount " . $invoice->netamount . " amount " . $invoice->amount);
251 # die $invoice->transaction_description;
253 $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
256 $main::lxdebug->leave_sub();
260 sub _calculate_dates {
262 return $config->calculate_invoice_dates(end_date => DateTime->today_local);
266 my ($posted_invoices, $printed_invoices) = @_;
268 my %config = %::lx_office_conf;
270 return if !$config{periodic_invoices} || !$config{periodic_invoices}->{send_email_to} || !scalar @{ $posted_invoices };
272 my $user = SL::DB::Manager::AuthUser->find_by(login => $config{periodic_invoices}->{send_email_to});
273 my $email = $user ? $user->get_config_value('email') : undef;
275 return unless $email;
277 my $template = Template->new({ 'INTERPOLATE' => 0,
283 return unless $template;
285 my $email_template = $config{periodic_invoices}->{email_template};
286 my $filename = $email_template || ( (SL::DB::Default->get->templates || "templates/webpages") . "/oe/periodic_invoices_email.txt" );
287 my %params = ( POSTED_INVOICES => $posted_invoices,
288 PRINTED_INVOICES => $printed_invoices );
291 $template->process($filename, \%params, \$output);
293 my $mail = Mailer->new;
294 $mail->{from} = $config{periodic_invoices}->{email_from};
295 $mail->{to} = $email;
296 $mail->{subject} = $config{periodic_invoices}->{email_subject};
297 $mail->{content_type} = $filename =~ m/.html$/ ? 'text/html' : 'text/plain';
298 $mail->{message} = $output;
304 my ($invoice, $config) = @_;
306 return unless $config->print && $config->printer_id && $config->printer->printer_command;
308 my $form = Form->new;
309 $invoice->flatten_to_form($form, format_amounts => 1);
311 $form->{printer_code} = $config->printer->template_code;
312 $form->{copies} = $config->copies;
313 $form->{formname} = $form->{type};
314 $form->{format} = 'pdf';
315 $form->{media} = 'printer';
316 $form->{OUT} = $config->printer->printer_command;
317 $form->{OUT_MODE} = '|-';
319 $form->{TEMPLATE_DRIVER_OPTIONS} = {
320 variable_content_types => {
321 longdescription => 'html',
327 $form->prepare_for_printing;
329 $form->throw_on_error(sub {
331 $form->parse_template(\%::myconfig);
333 } || die $EVAL_ERROR->getMessage;
347 SL::BackgroundJob::CleanBackgroundJobHistory - Create periodic
352 Iterate over all periodic invoice configurations, extend them if
353 applicable, calculate the dates for which invoices have to be posted
354 and post those invoices by converting the order into an invoice for
363 Strings like month names are hardcoded to German in this file.
369 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>