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         path     => $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>