1 package SL::Controller::Order;
 
   4 use parent qw(SL::Controller::Base);
 
   6 use SL::Helper::Flash qw(flash_later);
 
   7 use SL::Presenter::Tag qw(select_tag hidden_tag);
 
   8 use SL::Locale::String qw(t8);
 
   9 use SL::SessionFile::Random;
 
  13 use SL::Util qw(trim);
 
  19 use SL::DB::PartsGroup;
 
  22 use SL::DB::RecordLink;
 
  24 use SL::Helper::CreatePDF qw(:all);
 
  25 use SL::Helper::PrintOptions;
 
  26 use SL::Helper::ShippedQty;
 
  27 use SL::Helper::UserPreferences::PositionsScrollbar;
 
  29 use SL::Controller::Helper::GetModels;
 
  31 use List::Util qw(first);
 
  32 use List::UtilsBy qw(sort_by uniq_by);
 
  33 use List::MoreUtils qw(any none pairwise first_index);
 
  34 use English qw(-no_match_vars);
 
  39 use Rose::Object::MakeMethods::Generic
 
  41  scalar => [ qw(item_ids_to_delete) ],
 
  42  'scalar --get_set_init' => [ qw(order valid_types type cv p multi_items_models all_price_factors search_cvpartnumber) ],
 
  47 __PACKAGE__->run_before('check_auth');
 
  49 __PACKAGE__->run_before('recalc',
 
  50                         only => [ qw(save save_as_new save_and_delivery_order save_and_invoice print send_email) ]);
 
  52 __PACKAGE__->run_before('get_unalterable_data',
 
  53                         only => [ qw(save save_as_new save_and_delivery_order save_and_invoice print send_email) ]);
 
  63   $self->order->transdate(DateTime->now_local());
 
  64   my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval       :
 
  65                    $self->{type} eq 'sales_order'     ? $::instance_conf->get_delivery_date_interval : 1;
 
  66   $self->order->reqdate(DateTime->today_local->next_workday(extra_days => $extra_days)) if !$self->order->reqdate;
 
  72     title => $self->get_title_for('add'),
 
  73     %{$self->{template_args}}
 
  77 # edit an existing order
 
  85     # this is to edit an order from an unsaved order object
 
  87     # set item ids to new fake id, to identify them as new items
 
  88     foreach my $item (@{$self->order->items_sorted}) {
 
  89       $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
 
  91     # trigger rendering values for second row/longdescription as hidden,
 
  92     # because they are loaded only on demand. So we need to keep the values
 
  94     $_->{render_second_row}      = 1 for @{ $self->order->items_sorted };
 
  95     $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
 
 102     title => $self->get_title_for('edit'),
 
 103     %{$self->{template_args}}
 
 107 # edit a collective order (consisting of one or more existing orders)
 
 108 sub action_edit_collective {
 
 112   my @multi_ids = map {
 
 113     $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
 
 114   } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
 
 116   # fall back to add if no ids are given
 
 117   if (scalar @multi_ids == 0) {
 
 122   # fall back to save as new if only one id is given
 
 123   if (scalar @multi_ids == 1) {
 
 124     $self->order(SL::DB::Order->new(id => $multi_ids[0])->load);
 
 125     $self->action_save_as_new();
 
 129   # make new order from given orders
 
 130   my @multi_orders = map { SL::DB::Order->new(id => $_)->load } @multi_ids;
 
 131   $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
 
 132   $self->order(SL::DB::Order->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
 
 134   $self->action_edit();
 
 141   my $errors = $self->delete();
 
 143   if (scalar @{ $errors }) {
 
 144     $self->js->flash('error', $_) foreach @{ $errors };
 
 145     return $self->js->render();
 
 148   my $text = $self->type eq sales_order_type()       ? $::locale->text('The order has been deleted')
 
 149            : $self->type eq purchase_order_type()    ? $::locale->text('The order has been deleted')
 
 150            : $self->type eq sales_quotation_type()   ? $::locale->text('The quotation has been deleted')
 
 151            : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been deleted')
 
 153   flash_later('info', $text);
 
 155   my @redirect_params = (
 
 160   $self->redirect_to(@redirect_params);
 
 167   my $errors = $self->save();
 
 169   if (scalar @{ $errors }) {
 
 170     $self->js->flash('error', $_) foreach @{ $errors };
 
 171     return $self->js->render();
 
 174   my $text = $self->type eq sales_order_type()       ? $::locale->text('The order has been saved')
 
 175            : $self->type eq purchase_order_type()    ? $::locale->text('The order has been saved')
 
 176            : $self->type eq sales_quotation_type()   ? $::locale->text('The quotation has been saved')
 
 177            : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
 
 179   flash_later('info', $text);
 
 181   my @redirect_params = (
 
 184     id     => $self->order->id,
 
 187   $self->redirect_to(@redirect_params);
 
 190 # save the order as new document an open it for edit
 
 191 sub action_save_as_new {
 
 194   my $order = $self->order;
 
 197     $self->js->flash('error', t8('This object has not been saved yet.'));
 
 198     return $self->js->render();
 
 201   # load order from db to check if values changed
 
 202   my $saved_order = SL::DB::Order->new(id => $order->id)->load;
 
 205   # Lets assign a new number if the user hasn't changed the previous one.
 
 206   # If it has been changed manually then use it as-is.
 
 207   $new_attrs{number}    = (trim($order->number) eq $saved_order->number)
 
 209                         : trim($order->number);
 
 211   # Clear transdate unless changed
 
 212   $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
 
 213                         ? DateTime->today_local
 
 216   # Set new reqdate unless changed
 
 217   if ($order->reqdate == $saved_order->reqdate) {
 
 218     my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval       :
 
 219                      $self->{type} eq 'sales_order'     ? $::instance_conf->get_delivery_date_interval : 1;
 
 220     $new_attrs{reqdate} = DateTime->today_local->next_workday(extra_days => $extra_days);
 
 222     $new_attrs{reqdate} = $order->reqdate;
 
 226   $new_attrs{employee}  = SL::DB::Manager::Employee->current;
 
 228   # Create new record from current one
 
 229   $self->order(SL::DB::Order->new_from($order, destination_type => $order->type, attributes => \%new_attrs));
 
 231   # no linked records on save as new
 
 232   delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
 
 235   $self->action_save();
 
 240 # This is called if "print" is pressed in the print dialog.
 
 241 # If PDF creation was requested and succeeded, the pdf is stored in a session
 
 242 # file and the filename is stored as session value with an unique key. A
 
 243 # javascript function with this key is then called. This function calls the
 
 244 # download action below (action_download_pdf), which offers the file for
 
 249   my $errors = $self->save();
 
 251   if (scalar @{ $errors }) {
 
 252     $self->js->flash('error', $_) foreach @{ $errors };
 
 253     return $self->js->render();
 
 256   $self->js_reset_order_and_item_ids_after_save;
 
 258   my $format      = $::form->{print_options}->{format};
 
 259   my $media       = $::form->{print_options}->{media};
 
 260   my $formname    = $::form->{print_options}->{formname};
 
 261   my $copies      = $::form->{print_options}->{copies};
 
 262   my $groupitems  = $::form->{print_options}->{groupitems};
 
 264   # only pdf and opendocument by now
 
 265   if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf)) {
 
 266     return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
 
 269   # only screen or printer by now
 
 270   if (none { $media eq $_ } qw(screen printer)) {
 
 271     return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
 
 275   $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
 
 277   # create a form for generate_attachment_filename
 
 278   my $form   = Form->new;
 
 279   $form->{$self->nr_key()}  = $self->order->number;
 
 280   $form->{type}             = $self->type;
 
 281   $form->{format}           = $format;
 
 282   $form->{formname}         = $formname;
 
 283   $form->{language}         = '_' . $language->template_code if $language;
 
 284   my $pdf_filename          = $form->generate_attachment_filename();
 
 287   my @errors = generate_pdf($self->order, \$pdf, { format     => $format,
 
 288                                                    formname   => $formname,
 
 289                                                    language   => $language,
 
 290                                                    groupitems => $groupitems });
 
 291   if (scalar @errors) {
 
 292     return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
 
 295   if ($media eq 'screen') {
 
 297     my $sfile = SL::SessionFile::Random->new(mode => "w");
 
 298     $sfile->fh->print($pdf);
 
 301     my $key = join('_', Time::HiRes::gettimeofday(), int rand 1000000000000);
 
 302     $::auth->set_session_value("Order::print-${key}" => $sfile->file_name);
 
 305     ->run('kivi.Order.download_pdf', $pdf_filename, $key)
 
 306     ->flash('info', t8('The PDF has been created'));
 
 308   } elsif ($media eq 'printer') {
 
 310     my $printer_id = $::form->{print_options}->{printer_id};
 
 311     SL::DB::Printer->new(id => $printer_id)->load->print_document(
 
 316     $self->js->flash('info', t8('The PDF has been printed'));
 
 319   # copy file to webdav folder
 
 320   if ($self->order->number && $::instance_conf->get_webdav_documents) {
 
 321     my $webdav = SL::Webdav->new(
 
 323       number   => $self->order->number,
 
 325     my $webdav_file = SL::Webdav::File->new(
 
 327       filename => $pdf_filename,
 
 330       $webdav_file->store(data => \$pdf);
 
 333       $self->js->flash('error', t8('Storing PDF to webdav folder failed: #1', $@));
 
 336   if ($self->order->number && $::instance_conf->get_doc_storage) {
 
 338       SL::File->save(object_id     => $self->order->id,
 
 339                      object_type   => $self->type,
 
 340                      mime_type     => 'application/pdf',
 
 342                      file_type     => 'document',
 
 343                      file_name     => $pdf_filename,
 
 344                      file_contents => $pdf);
 
 347       $self->js->flash('error', t8('Storing PDF in storage backend failed: #1', $@));
 
 353 # offer pdf for download
 
 355 # It needs to get the key for the session value to get the pdf file.
 
 356 sub action_download_pdf {
 
 359   my $key = $::form->{key};
 
 360   my $tmp_filename = $::auth->get_session_value("Order::print-${key}");
 
 361   return $self->send_file(
 
 363     type => 'application/pdf',
 
 364     name => $::form->{pdf_filename},
 
 368 # open the email dialog
 
 369 sub action_show_email_dialog {
 
 372   my $cv_method = $self->cv;
 
 374   if (!$self->order->$cv_method) {
 
 375     return $self->js->flash('error', $self->cv eq 'customer' ? t8('Cannot send E-mail without customer given') : t8('Cannot send E-mail without vendor given'))
 
 380   $email_form->{to}   = $self->order->contact->cp_email if $self->order->contact;
 
 381   $email_form->{to} ||= $self->order->$cv_method->email;
 
 382   $email_form->{cc}   = $self->order->$cv_method->cc;
 
 383   $email_form->{bcc}  = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
 
 384   # Todo: get addresses from shipto, if any
 
 386   my $form = Form->new;
 
 387   $form->{$self->nr_key()}  = $self->order->number;
 
 388   $form->{formname}         = $self->type;
 
 389   $form->{type}             = $self->type;
 
 390   $form->{language}         = 'de';
 
 391   $form->{format}           = 'pdf';
 
 393   $email_form->{subject}             = $form->generate_email_subject();
 
 394   $email_form->{attachment_filename} = $form->generate_attachment_filename();
 
 395   $email_form->{message}             = $form->generate_email_body();
 
 396   $email_form->{js_send_function}    = 'kivi.Order.send_email()';
 
 398   my %files = $self->get_files_for_email_dialog();
 
 399   my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
 
 400                                   email_form  => $email_form,
 
 401                                   show_bcc    => $::auth->assert('email_bcc', 'may fail'),
 
 403                                   is_customer => $self->cv eq 'customer',
 
 407       ->run('kivi.Order.show_email_dialog', $dialog_html)
 
 414 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
 
 415 sub action_send_email {
 
 418   my $errors = $self->save();
 
 420   if (scalar @{ $errors }) {
 
 421     $self->js->run('kivi.Order.close_email_dialog');
 
 422     $self->js->flash('error', $_) foreach @{ $errors };
 
 423     return $self->js->render();
 
 426   $self->js_reset_order_and_item_ids_after_save;
 
 428   my $email_form  = delete $::form->{email_form};
 
 429   my %field_names = (to => 'email');
 
 431   $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
 
 433   # for Form::cleanup which may be called in Form::send_email
 
 434   $::form->{cwd}    = getcwd();
 
 435   $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
 
 437   $::form->{$_}     = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
 
 438   $::form->{media}  = 'email';
 
 440   if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
 
 442     $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
 
 445     my @errors = generate_pdf($self->order, \$pdf, {media      => $::form->{media},
 
 446                                                     format     => $::form->{print_options}->{format},
 
 447                                                     formname   => $::form->{print_options}->{formname},
 
 448                                                     language   => $language,
 
 449                                                     groupitems => $::form->{print_options}->{groupitems}});
 
 450     if (scalar @errors) {
 
 451       return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
 
 454     my $sfile = SL::SessionFile::Random->new(mode => "w");
 
 455     $sfile->fh->print($pdf);
 
 458     $::form->{tmpfile} = $sfile->file_name;
 
 459     $::form->{tmpdir}  = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
 
 462   $::form->send_email(\%::myconfig, 'pdf');
 
 465   my $intnotes = $self->order->intnotes;
 
 466   $intnotes   .= "\n\n" if $self->order->intnotes;
 
 467   $intnotes   .= t8('[email]')                                                                                        . "\n";
 
 468   $intnotes   .= t8('Date')       . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
 
 469   $intnotes   .= t8('To (email)') . ": " . $::form->{email}                                                           . "\n";
 
 470   $intnotes   .= t8('Cc')         . ": " . $::form->{cc}                                                              . "\n"    if $::form->{cc};
 
 471   $intnotes   .= t8('Bcc')        . ": " . $::form->{bcc}                                                             . "\n"    if $::form->{bcc};
 
 472   $intnotes   .= t8('Subject')    . ": " . $::form->{subject}                                                         . "\n\n";
 
 473   $intnotes   .= t8('Message')    . ": " . $::form->{message};
 
 475   $self->order->update_attributes(intnotes => $intnotes);
 
 478       ->val('#order_intnotes', $intnotes)
 
 479       ->run('kivi.Order.close_email_dialog')
 
 480       ->flash('info', t8('The email has been sent.'))
 
 484 # open the periodic invoices config dialog
 
 486 # If there are values in the form (i.e. dialog was opened before),
 
 487 # then use this values. Create new ones, else.
 
 488 sub action_show_periodic_invoices_config_dialog {
 
 491   my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
 
 492   $config  ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
 
 493   $config  ||= SL::DB::PeriodicInvoicesConfig->new(periodicity             => 'm',
 
 494                                                    order_value_periodicity => 'p', # = same as periodicity
 
 495                                                    start_date_as_date      => $::form->{transdate_as_date} || $::form->current_date,
 
 496                                                    extend_automatically_by => 12,
 
 498                                                    email_subject           => GenericTranslations->get(
 
 499                                                                                 language_id      => $::form->{language_id},
 
 500                                                                                 translation_type =>"preset_text_periodic_invoices_email_subject"),
 
 501                                                    email_body              => GenericTranslations->get(
 
 502                                                                                 language_id      => $::form->{language_id},
 
 503                                                                                 translation_type =>"preset_text_periodic_invoices_email_body"),
 
 505   $config->periodicity('m')             if none { $_ eq $config->periodicity             }       @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
 
 506   $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
 
 508   $::form->get_lists(printers => "ALL_PRINTERS",
 
 509                      charts   => { key       => 'ALL_CHARTS',
 
 510                                    transdate => 'current_date' });
 
 512   $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
 
 514   if ($::form->{customer_id}) {
 
 515     $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
 
 516     $::form->{email_recipient_invoice_address} = SL::DB::Manager::Customer->find_by(id => $::form->{customer_id})->invoice_mail;
 
 519   $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
 
 521                 popup_js_close_function  => 'kivi.Order.close_periodic_invoices_config_dialog()',
 
 522                 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
 
 527 # assign the values of the periodic invoices config dialog
 
 528 # as yaml in the hidden tag and set the status.
 
 529 sub action_assign_periodic_invoices_config {
 
 532   $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
 
 534   my $config = { active                     => $::form->{active}       ? 1 : 0,
 
 535                  terminated                 => $::form->{terminated}   ? 1 : 0,
 
 536                  direct_debit               => $::form->{direct_debit} ? 1 : 0,
 
 537                  periodicity                => (any { $_ eq $::form->{periodicity}             }       @SL::DB::PeriodicInvoicesConfig::PERIODICITIES)              ? $::form->{periodicity}             : 'm',
 
 538                  order_value_periodicity    => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
 
 539                  start_date_as_date         => $::form->{start_date_as_date},
 
 540                  end_date_as_date           => $::form->{end_date_as_date},
 
 541                  first_billing_date_as_date => $::form->{first_billing_date_as_date},
 
 542                  print                      => $::form->{print}      ? 1                         : 0,
 
 543                  printer_id                 => $::form->{print}      ? $::form->{printer_id} * 1 : undef,
 
 544                  copies                     => $::form->{copies} * 1 ? $::form->{copies}         : 1,
 
 545                  extend_automatically_by    => $::form->{extend_automatically_by}    * 1 || undef,
 
 546                  ar_chart_id                => $::form->{ar_chart_id} * 1,
 
 547                  send_email                 => $::form->{send_email} ? 1 : 0,
 
 548                  email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
 
 549                  email_recipient_address    => $::form->{email_recipient_address},
 
 550                  email_sender               => $::form->{email_sender},
 
 551                  email_subject              => $::form->{email_subject},
 
 552                  email_body                 => $::form->{email_body},
 
 555   my $periodic_invoices_config = SL::YAML::Dump($config);
 
 557   my $status = $self->get_periodic_invoices_status($config);
 
 560     ->remove('#order_periodic_invoices_config')
 
 561     ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
 
 562     ->run('kivi.Order.close_periodic_invoices_config_dialog')
 
 563     ->html('#periodic_invoices_status', $status)
 
 564     ->flash('info', t8('The periodic invoices config has been assigned.'))
 
 568 sub action_get_has_active_periodic_invoices {
 
 571   my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
 
 572   $config  ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
 
 574   my $has_active_periodic_invoices =
 
 575        $self->type eq sales_order_type()
 
 578     && (!$config->end_date || ($config->end_date > DateTime->today_local))
 
 579     && $config->get_previous_billed_period_start_date;
 
 581   $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
 
 584 # save the order and redirect to the frontend subroutine for a new
 
 586 sub action_save_and_delivery_order {
 
 589   my $errors = $self->save();
 
 591   if (scalar @{ $errors }) {
 
 592     $self->js->flash('error', $_) foreach @{ $errors };
 
 593     return $self->js->render();
 
 596   my $text = $self->type eq sales_order_type()       ? $::locale->text('The order has been saved')
 
 597            : $self->type eq purchase_order_type()    ? $::locale->text('The order has been saved')
 
 598            : $self->type eq sales_quotation_type()   ? $::locale->text('The quotation has been saved')
 
 599            : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
 
 601   flash_later('info', $text);
 
 603   my @redirect_params = (
 
 604     controller => 'oe.pl',
 
 605     action     => 'oe_delivery_order_from_order',
 
 606     id         => $self->order->id,
 
 609   $self->redirect_to(@redirect_params);
 
 612 # save the order and redirect to the frontend subroutine for a new
 
 614 sub action_save_and_invoice {
 
 617   my $errors = $self->save();
 
 619   if (scalar @{ $errors }) {
 
 620     $self->js->flash('error', $_) foreach @{ $errors };
 
 621     return $self->js->render();
 
 624   my $text = $self->type eq sales_order_type()       ? $::locale->text('The order has been saved')
 
 625            : $self->type eq purchase_order_type()    ? $::locale->text('The order has been saved')
 
 626            : $self->type eq sales_quotation_type()   ? $::locale->text('The quotation has been saved')
 
 627            : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
 
 629   flash_later('info', $text);
 
 631   my @redirect_params = (
 
 632     controller => 'oe.pl',
 
 633     action     => 'oe_invoice_from_order',
 
 634     id         => $self->order->id,
 
 637   $self->redirect_to(@redirect_params);
 
 640 # workflow from sales quotation to sales order
 
 641 sub action_sales_order {
 
 642   $_[0]->workflow_sales_or_purchase_order();
 
 645 # workflow from rfq to purchase order
 
 646 sub action_purchase_order {
 
 647   $_[0]->workflow_sales_or_purchase_order();
 
 650 # set form elements in respect to a changed customer or vendor
 
 652 # This action is called on an change of the customer/vendor picker.
 
 653 sub action_customer_vendor_changed {
 
 656   setup_order_from_cv($self->order);
 
 659   my $cv_method = $self->cv;
 
 661   if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
 
 662     $self->js->show('#cp_row');
 
 664     $self->js->hide('#cp_row');
 
 667   if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
 
 668     $self->js->show('#shipto_row');
 
 670     $self->js->hide('#shipto_row');
 
 673   $self->js->val( '#order_salesman_id',      $self->order->salesman_id)        if $self->order->is_sales;
 
 676     ->replaceWith('#order_cp_id',            $self->build_contact_select)
 
 677     ->replaceWith('#order_shipto_id',        $self->build_shipto_select)
 
 678     ->replaceWith('#business_info_row',      $self->build_business_info_row)
 
 679     ->val(        '#order_taxzone_id',       $self->order->taxzone_id)
 
 680     ->val(        '#order_taxincluded',      $self->order->taxincluded)
 
 681     ->val(        '#order_payment_id',       $self->order->payment_id)
 
 682     ->val(        '#order_delivery_term_id', $self->order->delivery_term_id)
 
 683     ->val(        '#order_intnotes',         $self->order->intnotes)
 
 684     ->val(        '#language_id',            $self->order->$cv_method->language_id)
 
 685     ->focus(      '#order_' . $self->cv . '_id');
 
 687   $self->js_redisplay_amounts_and_taxes;
 
 688   $self->js_redisplay_cvpartnumbers;
 
 692 # open the dialog for customer/vendor details
 
 693 sub action_show_customer_vendor_details_dialog {
 
 696   my $is_customer = 'customer' eq $::form->{vc};
 
 699     $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
 
 701     $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
 
 704   my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
 
 705   $details{discount_as_percent} = $cv->discount_as_percent;
 
 706   $details{creditlimt}          = $cv->creditlimit_as_number;
 
 707   $details{business}            = $cv->business->description      if $cv->business;
 
 708   $details{language}            = $cv->language_obj->description  if $cv->language_obj;
 
 709   $details{delivery_terms}      = $cv->delivery_term->description if $cv->delivery_term;
 
 710   $details{payment_terms}       = $cv->payment->description       if $cv->payment;
 
 711   $details{pricegroup}          = $cv->pricegroup->pricegroup     if $is_customer && $cv->pricegroup;
 
 713   foreach my $entry (@{ $cv->shipto }) {
 
 714     push @{ $details{SHIPTO} },   { map { $_ => $entry->$_ } @{$entry->meta->columns} };
 
 716   foreach my $entry (@{ $cv->contacts }) {
 
 717     push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
 
 720   $_[0]->render('common/show_vc_details', { layout => 0 },
 
 721                 is_customer => $is_customer,
 
 726 # called if a unit in an existing item row is changed
 
 727 sub action_unit_changed {
 
 730   my $idx  = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
 
 731   my $item = $self->order->items_sorted->[$idx];
 
 733   my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
 
 734   $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
 
 739     ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
 
 740   $self->js_redisplay_line_values;
 
 741   $self->js_redisplay_amounts_and_taxes;
 
 745 # add an item row for a new item entered in the input row
 
 746 sub action_add_item {
 
 749   my $form_attr = $::form->{add_item};
 
 751   return unless $form_attr->{parts_id};
 
 753   my $item = new_item($self->order, $form_attr);
 
 755   $self->order->add_items($item);
 
 759   $self->get_item_cvpartnumber($item);
 
 761   my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
 
 762   my $row_as_html = $self->p->render('order/tabs/_row',
 
 766                                      ALL_PRICE_FACTORS   => $self->all_price_factors,
 
 767                                      SEARCH_CVPARTNUMBER => $self->search_cvpartnumber,
 
 771     ->append('#row_table_id', $row_as_html);
 
 773   if ( $item->part->is_assortment ) {
 
 774     $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
 
 775     foreach my $assortment_item ( @{$item->part->assortment_items} ) {
 
 776       my $attr = { parts_id => $assortment_item->parts_id,
 
 777                    qty      => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
 
 778                    unit     => $assortment_item->unit,
 
 779                    description => $assortment_item->part->description,
 
 781       my $item = new_item($self->order, $attr);
 
 783       # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
 
 784       $item->discount(1) unless $assortment_item->charge;
 
 786       $self->order->add_items( $item );
 
 788       $self->get_item_cvpartnumber($item);
 
 789       my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
 
 790       my $row_as_html = $self->p->render('order/tabs/_row',
 
 794                                          ALL_PRICE_FACTORS   => $self->all_price_factors,
 
 795                                          SEARCH_CVPARTNUMBER => $self->search_cvpartnumber,
 
 798         ->append('#row_table_id', $row_as_html);
 
 803     ->val('.add_item_input', '')
 
 804     ->run('kivi.Order.init_row_handlers')
 
 805     ->run('kivi.Order.row_table_scroll_down')
 
 806     ->run('kivi.Order.renumber_positions')
 
 807     ->focus('#add_item_parts_id_name');
 
 809   $self->js_redisplay_amounts_and_taxes;
 
 813 # open the dialog for entering multiple items at once
 
 814 sub action_show_multi_items_dialog {
 
 815   $_[0]->render('order/tabs/_multi_items_dialog', { layout => 0 },
 
 816                 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
 
 819 # update the filter results in the multi item dialog
 
 820 sub action_multi_items_update_result {
 
 823   $::form->{multi_items}->{filter}->{obsolete} = 0;
 
 825   my $count = $_[0]->multi_items_models->count;
 
 828     my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
 
 829     $_[0]->render($text, { layout => 0 });
 
 830   } elsif ($count > $max_count) {
 
 831     my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
 
 832     $_[0]->render($text, { layout => 0 });
 
 834     my $multi_items = $_[0]->multi_items_models->get;
 
 835     $_[0]->render('order/tabs/_multi_items_result', { layout => 0 },
 
 836                   multi_items => $multi_items);
 
 840 # add item rows for multiple items at once
 
 841 sub action_add_multi_items {
 
 844   my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_multi_items} };
 
 845   return $self->js->render() unless scalar @form_attr;
 
 848   foreach my $attr (@form_attr) {
 
 849     my $item = new_item($self->order, $attr);
 
 851     if ( $item->part->is_assortment ) {
 
 852       foreach my $assortment_item ( @{$item->part->assortment_items} ) {
 
 853         my $attr = { parts_id => $assortment_item->parts_id,
 
 854                      qty      => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
 
 855                      unit     => $assortment_item->unit,
 
 856                      description => $assortment_item->part->description,
 
 858         my $item = new_item($self->order, $attr);
 
 860         # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
 
 861         $item->discount(1) unless $assortment_item->charge;
 
 866   $self->order->add_items(@items);
 
 870   foreach my $item (@items) {
 
 871     $self->get_item_cvpartnumber($item);
 
 872     my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
 
 873     my $row_as_html = $self->p->render('order/tabs/_row',
 
 877                                        ALL_PRICE_FACTORS   => $self->all_price_factors,
 
 878                                        SEARCH_CVPARTNUMBER => $self->search_cvpartnumber,
 
 881     $self->js->append('#row_table_id', $row_as_html);
 
 885     ->run('kivi.Order.close_multi_items_dialog')
 
 886     ->run('kivi.Order.init_row_handlers')
 
 887     ->run('kivi.Order.row_table_scroll_down')
 
 888     ->run('kivi.Order.renumber_positions')
 
 889     ->focus('#add_item_parts_id_name');
 
 891   $self->js_redisplay_amounts_and_taxes;
 
 895 # recalculate all linetotals, amounts and taxes and redisplay them
 
 896 sub action_recalc_amounts_and_taxes {
 
 901   $self->js_redisplay_line_values;
 
 902   $self->js_redisplay_amounts_and_taxes;
 
 906 # redisplay item rows if they are sorted by an attribute
 
 907 sub action_reorder_items {
 
 911     partnumber   => sub { $_[0]->part->partnumber },
 
 912     description  => sub { $_[0]->description },
 
 913     qty          => sub { $_[0]->qty },
 
 914     sellprice    => sub { $_[0]->sellprice },
 
 915     discount     => sub { $_[0]->discount },
 
 916     cvpartnumber => sub { $_[0]->{cvpartnumber} },
 
 919   $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
 
 921   my $method = $sort_keys{$::form->{order_by}};
 
 922   my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
 
 923   if ($::form->{sort_dir}) {
 
 924     if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
 
 925       @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
 
 927       @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
 
 930     if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
 
 931       @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
 
 933       @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
 
 937     ->run('kivi.Order.redisplay_items', \@to_sort)
 
 941 # show the popup to choose a price/discount source
 
 942 sub action_price_popup {
 
 945   my $idx  = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
 
 946   my $item = $self->order->items_sorted->[$idx];
 
 948   $self->render_price_dialog($item);
 
 951 # get the longdescription for an item if the dialog to enter/change the
 
 952 # longdescription was opened and the longdescription is empty
 
 954 # If this item is new, get the longdescription from Part.
 
 955 # Otherwise get it from OrderItem.
 
 956 sub action_get_item_longdescription {
 
 959   if ($::form->{item_id}) {
 
 960     $longdescription = SL::DB::OrderItem->new(id => $::form->{item_id})->load->longdescription;
 
 961   } elsif ($::form->{parts_id}) {
 
 962     $longdescription = SL::DB::Part->new(id => $::form->{parts_id})->load->notes;
 
 964   $_[0]->render(\ $longdescription, { type => 'text' });
 
 967 # load the second row for one or more items
 
 969 # This action gets the html code for all items second rows by rendering a template for
 
 970 # the second row and sets the html code via client js.
 
 971 sub action_load_second_rows {
 
 974   $self->recalc() if $self->order->is_sales; # for margin calculation
 
 976   foreach my $item_id (@{ $::form->{item_ids} }) {
 
 977     my $idx  = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
 
 978     my $item = $self->order->items_sorted->[$idx];
 
 980     $self->js_load_second_row($item, $item_id, 0);
 
 983   $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
 
 988 sub js_load_second_row {
 
 989   my ($self, $item, $item_id, $do_parse) = @_;
 
 992     # Parse values from form (they are formated while rendering (template)).
 
 993     # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
 
 994     # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
 
 995     foreach my $var (@{ $item->cvars_by_config }) {
 
 996       $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
 
 998     $item->parse_custom_variable_values;
 
1001   my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
 
1004     ->html('#second_row_' . $item_id, $row_as_html)
 
1005     ->data('#second_row_' . $item_id, 'loaded', 1);
 
1008 sub js_redisplay_line_values {
 
1011   my $is_sales = $self->order->is_sales;
 
1013   # sales orders with margins
 
1018        $::form->format_amount(\%::myconfig, $_->{linetotal},     2, 0),
 
1019        $::form->format_amount(\%::myconfig, $_->{marge_total},   2, 0),
 
1020        $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
 
1021       ]} @{ $self->order->items_sorted };
 
1025        $::form->format_amount(\%::myconfig, $_->{linetotal},     2, 0),
 
1026       ]} @{ $self->order->items_sorted };
 
1030     ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
 
1033 sub js_redisplay_amounts_and_taxes {
 
1036   if (scalar @{ $self->{taxes} }) {
 
1037     $self->js->show('#taxincluded_row_id');
 
1039     $self->js->hide('#taxincluded_row_id');
 
1042   if ($self->order->taxincluded) {
 
1043     $self->js->hide('#subtotal_row_id');
 
1045     $self->js->show('#subtotal_row_id');
 
1048   if ($self->order->is_sales) {
 
1049     my $is_neg = $self->order->marge_total < 0;
 
1051       ->html('#marge_total_id',   $::form->format_amount(\%::myconfig, $self->order->marge_total,   2))
 
1052       ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
 
1053       ->action_if( $is_neg, 'addClass',    '#marge_total_id',        'plus0')
 
1054       ->action_if( $is_neg, 'addClass',    '#marge_percent_id',      'plus0')
 
1055       ->action_if( $is_neg, 'addClass',    '#marge_percent_sign_id', 'plus0')
 
1056       ->action_if(!$is_neg, 'removeClass', '#marge_total_id',        'plus0')
 
1057       ->action_if(!$is_neg, 'removeClass', '#marge_percent_id',      'plus0')
 
1058       ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
 
1062     ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
 
1063     ->html('#amount_id',    $::form->format_amount(\%::myconfig, $self->order->amount,    -2))
 
1064     ->remove('.tax_row')
 
1065     ->insertBefore($self->build_tax_rows, '#amount_row_id');
 
1068 sub js_redisplay_cvpartnumbers {
 
1071   $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
 
1073   my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
 
1076     ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
 
1079 sub js_reset_order_and_item_ids_after_save {
 
1083     ->val('#id', $self->order->id)
 
1084     ->val('#converted_from_oe_id', '')
 
1085     ->val('#order_' . $self->nr_key(), $self->order->number);
 
1088   foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
 
1089     next if !$self->order->items_sorted->[$idx]->id;
 
1090     next if $form_item_id !~ m{^new};
 
1092       ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
 
1093       ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
 
1094       ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
 
1098   $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
 
1105 sub init_valid_types {
 
1106   [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
 
1112   if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
 
1113     die "Not a valid type for order";
 
1116   $self->type($::form->{type});
 
1122   my $cv = (any { $self->type eq $_ } (sales_order_type(),    sales_quotation_type()))   ? 'customer'
 
1123          : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
 
1124          : die "Not a valid type for order";
 
1129 sub init_search_cvpartnumber {
 
1132   my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
 
1133   my $search_cvpartnumber;
 
1134   $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
 
1135   $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel()        if $self->cv eq 'vendor';
 
1137   return $search_cvpartnumber;
 
1148 # model used to filter/display the parts in the multi-items dialog
 
1149 sub init_multi_items_models {
 
1150   SL::Controller::Helper::GetModels->new(
 
1151     controller     => $_[0],
 
1153     with_objects   => [ qw(unit_obj) ],
 
1154     disable_plugin => 'paginated',
 
1155     source         => $::form->{multi_items},
 
1161       partnumber  => t8('Partnumber'),
 
1162       description => t8('Description')}
 
1166 sub init_all_price_factors {
 
1167   SL::DB::Manager::PriceFactor->get_all;
 
1173   my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
 
1175   my $right   = $right_for->{ $self->type };
 
1176   $right    ||= 'DOES_NOT_EXIST';
 
1178   $::auth->assert($right);
 
1181 # build the selection box for contacts
 
1183 # Needed, if customer/vendor changed.
 
1184 sub build_contact_select {
 
1187   select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
 
1188     value_key  => 'cp_id',
 
1189     title_key  => 'full_name_dep',
 
1190     default    => $self->order->cp_id,
 
1192     style      => 'width: 300px',
 
1196 # build the selection box for shiptos
 
1198 # Needed, if customer/vendor changed.
 
1199 sub build_shipto_select {
 
1202   select_tag('order.shipto_id', [ $self->order->{$self->cv}->shipto ],
 
1203     value_key  => 'shipto_id',
 
1204     title_key  => 'displayable_id',
 
1205     default    => $self->order->shipto_id,
 
1207     style      => 'width: 300px',
 
1211 # render the info line for business
 
1213 # Needed, if customer/vendor changed.
 
1214 sub build_business_info_row
 
1216   $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
 
1219 # build the rows for displaying taxes
 
1221 # Called if amounts where recalculated and redisplayed.
 
1222 sub build_tax_rows {
 
1226   foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
 
1227     $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
 
1229   return $rows_as_html;
 
1233 sub render_price_dialog {
 
1234   my ($self, $record_item) = @_;
 
1236   my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
 
1240       'kivi.io.price_chooser_dialog',
 
1241       t8('Available Prices'),
 
1242       $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
 
1247 #     $self->js->text('#dialog_flash_error_content', join ' ', @errors);
 
1248 #     $self->js->show('#dialog_flash_error');
 
1257   return if !$::form->{id};
 
1259   $self->order(SL::DB::Order->new(id => $::form->{id})->load);
 
1262 # load or create a new order object
 
1264 # And assign changes from the form to this object.
 
1265 # If the order is loaded from db, check if items are deleted in the form,
 
1266 # remove them form the object and collect them for removing from db on saving.
 
1267 # Then create/update items from form (via make_item) and add them.
 
1271   # add_items adds items to an order with no items for saving, but they cannot
 
1272   # be retrieved via items until the order is saved. Adding empty items to new
 
1273   # order here solves this problem.
 
1275   $order   = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
 
1276   $order ||= SL::DB::Order->new(orderitems => [],
 
1277                                 quotation  => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())));
 
1279   my $cv_id_method = $self->cv . '_id';
 
1280   if (!$::form->{id} && $::form->{$cv_id_method}) {
 
1281     $order->$cv_id_method($::form->{$cv_id_method});
 
1282     setup_order_from_cv($order);
 
1285   my $form_orderitems               = delete $::form->{order}->{orderitems};
 
1286   my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
 
1288   $order->assign_attributes(%{$::form->{order}});
 
1290   if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
 
1291     my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
 
1292     $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
 
1295   # remove deleted items
 
1296   $self->item_ids_to_delete([]);
 
1297   foreach my $idx (reverse 0..$#{$order->orderitems}) {
 
1298     my $item = $order->orderitems->[$idx];
 
1299     if (none { $item->id == $_->{id} } @{$form_orderitems}) {
 
1300       splice @{$order->orderitems}, $idx, 1;
 
1301       push @{$self->item_ids_to_delete}, $item->id;
 
1307   foreach my $form_attr (@{$form_orderitems}) {
 
1308     my $item = make_item($order, $form_attr);
 
1309     $item->position($pos);
 
1313   $order->add_items(grep {!$_->id} @items);
 
1318 # create or update items from form
 
1320 # Make item objects from form values. For items already existing read from db.
 
1321 # Create a new item else. And assign attributes.
 
1323   my ($record, $attr) = @_;
 
1326   $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
 
1328   my $is_new = !$item;
 
1330   # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
 
1331   # they cannot be retrieved via custom_variables until the order/orderitem is
 
1332   # saved. Adding empty custom_variables to new orderitem here solves this problem.
 
1333   $item ||= SL::DB::OrderItem->new(custom_variables => []);
 
1335   $item->assign_attributes(%$attr);
 
1336   $item->longdescription($item->part->notes)                     if $is_new && !defined $attr->{longdescription};
 
1337   $item->project_id($record->globalproject_id)                   if $is_new && !defined $attr->{project_id};
 
1338   $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if $is_new && !defined $attr->{lastcost_as_number};
 
1345 # This is used to add one item
 
1347   my ($record, $attr) = @_;
 
1349   my $item = SL::DB::OrderItem->new;
 
1351   # Remove attributes where the user left or set the inputs empty.
 
1352   # So these attributes will be undefined and we can distinguish them
 
1353   # from zero later on.
 
1354   for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
 
1355     delete $attr->{$_} if $attr->{$_} eq '';
 
1358   $item->assign_attributes(%$attr);
 
1360   my $part         = SL::DB::Part->new(id => $attr->{parts_id})->load;
 
1361   my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
 
1363   $item->unit($part->unit) if !$item->unit;
 
1366   if ( $part->is_assortment ) {
 
1367     # add assortment items with price 0, as the components carry the price
 
1368     $price_src = $price_source->price_from_source("");
 
1369     $price_src->price(0);
 
1370   } elsif (defined $item->sellprice) {
 
1371     $price_src = $price_source->price_from_source("");
 
1372     $price_src->price($item->sellprice);
 
1374     $price_src = $price_source->best_price
 
1375            ? $price_source->best_price
 
1376            : $price_source->price_from_source("");
 
1377     $price_src->price(0) if !$price_source->best_price;
 
1381   if (defined $item->discount) {
 
1382     $discount_src = $price_source->discount_from_source("");
 
1383     $discount_src->discount($item->discount);
 
1385     $discount_src = $price_source->best_discount
 
1386                   ? $price_source->best_discount
 
1387                   : $price_source->discount_from_source("");
 
1388     $discount_src->discount(0) if !$price_source->best_discount;
 
1392   $new_attr{part}                   = $part;
 
1393   $new_attr{description}            = $part->description     if ! $item->description;
 
1394   $new_attr{qty}                    = 1.0                    if ! $item->qty;
 
1395   $new_attr{price_factor_id}        = $part->price_factor_id if ! $item->price_factor_id;
 
1396   $new_attr{sellprice}              = $price_src->price;
 
1397   $new_attr{discount}               = $discount_src->discount;
 
1398   $new_attr{active_price_source}    = $price_src;
 
1399   $new_attr{active_discount_source} = $discount_src;
 
1400   $new_attr{longdescription}        = $part->notes           if ! defined $attr->{longdescription};
 
1401   $new_attr{project_id}             = $record->globalproject_id;
 
1402   $new_attr{lastcost}               = $record->is_sales ? $part->lastcost : 0;
 
1404   # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
 
1405   # they cannot be retrieved via custom_variables until the order/orderitem is
 
1406   # saved. Adding empty custom_variables to new orderitem here solves this problem.
 
1407   $new_attr{custom_variables} = [];
 
1409   $item->assign_attributes(%new_attr);
 
1414 sub setup_order_from_cv {
 
1417   $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id));
 
1419   $order->intnotes($order->customervendor->notes);
 
1421   if ($order->is_sales) {
 
1422     $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
 
1423     $order->taxincluded(defined($order->customer->taxincluded_checked)
 
1424                         ? $order->customer->taxincluded_checked
 
1425                         : $::myconfig{taxincluded_checked});
 
1430 # recalculate prices and taxes
 
1432 # Using the PriceTaxCalculator. Store linetotals in the item objects.
 
1436   # bb: todo: currency later
 
1437   $self->order->currency_id($::instance_conf->get_currency_id());
 
1439   my %pat = $self->order->calculate_prices_and_taxes();
 
1440   $self->{taxes} = [];
 
1441   foreach my $tax_chart_id (keys %{ $pat{taxes} }) {
 
1442     my $tax = SL::DB::Manager::Tax->find_by(chart_id => $tax_chart_id);
 
1444     my @amount_keys = grep { $pat{amounts}->{$_}->{tax_id} == $tax->id } keys %{ $pat{amounts} };
 
1445     push(@{ $self->{taxes} }, { amount    => $pat{taxes}->{$tax_chart_id},
 
1446                                 netamount => $pat{amounts}->{$amount_keys[0]}->{amount},
 
1450   pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
 
1453 # get data for saving, printing, ..., that is not changed in the form
 
1455 # Only cvars for now.
 
1456 sub get_unalterable_data {
 
1459   foreach my $item (@{ $self->order->items }) {
 
1460     # autovivify all cvars that are not in the form (cvars_by_config can do it).
 
1461     # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
 
1462     foreach my $var (@{ $item->cvars_by_config }) {
 
1463       $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
 
1465     $item->parse_custom_variable_values;
 
1471 # And remove related files in the spool directory
 
1476   my $db     = $self->order->db;
 
1478   $db->with_transaction(
 
1480       my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
 
1481       $self->order->delete;
 
1482       my $spool = $::lx_office_conf{paths}->{spool};
 
1483       unlink map { "$spool/$_" } @spoolfiles if $spool;
 
1486   }) || push(@{$errors}, $db->error);
 
1493 # And delete items that are deleted in the form.
 
1498   my $db     = $self->order->db;
 
1500   $db->with_transaction(sub {
 
1501     SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
 
1502     $self->order->save(cascade => 1);
 
1505     if ($::form->{converted_from_oe_id}) {
 
1506       my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
 
1507       foreach my $converted_from_oe_id (@converted_from_oe_ids) {
 
1508         my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
 
1509         $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
 
1510         $src->link_to_record($self->order);
 
1512       if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
 
1514         foreach (@{ $self->order->items_sorted }) {
 
1515           my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
 
1517           SL::DB::RecordLink->new(from_table => 'orderitems',
 
1518                                   from_id    => $from_id,
 
1519                                   to_table   => 'orderitems',
 
1527   }) || push(@{$errors}, $db->error);
 
1532 sub workflow_sales_or_purchase_order {
 
1536   my $errors = $self->save();
 
1538   if (scalar @{ $errors }) {
 
1539     $self->js->flash('error', $_) foreach @{ $errors };
 
1540     return $self->js->render();
 
1543   my $destination_type = $::form->{type} eq sales_quotation_type()   ? sales_order_type()
 
1544                        : $::form->{type} eq request_quotation_type() ? purchase_order_type()
 
1545                        : $::form->{type} eq purchase_order_type()    ? sales_order_type()
 
1546                        : $::form->{type} eq sales_order_type()       ? purchase_order_type()
 
1549   $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
 
1550   $self->{converted_from_oe_id} = delete $::form->{id};
 
1552   # set item ids to new fake id, to identify them as new items
 
1553   foreach my $item (@{$self->order->items_sorted}) {
 
1554     $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
 
1558   $::form->{type} = $destination_type;
 
1559   $self->type($self->init_type);
 
1560   $self->cv  ($self->init_cv);
 
1564   $self->get_unalterable_data();
 
1565   $self->pre_render();
 
1567   # trigger rendering values for second row/longdescription as hidden,
 
1568   # because they are loaded only on demand. So we need to keep the values
 
1570   $_->{render_second_row}      = 1 for @{ $self->order->items_sorted };
 
1571   $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
 
1575     title => $self->get_title_for('edit'),
 
1576     %{$self->{template_args}}
 
1584   $self->{all_taxzones}               = SL::DB::Manager::TaxZone->get_all_sorted();
 
1585   $self->{all_departments}            = SL::DB::Manager::Department->get_all_sorted();
 
1586   $self->{all_employees}              = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
 
1589   $self->{all_salesmen}               = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
 
1592   $self->{all_payment_terms}          = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
 
1594   $self->{all_delivery_terms}         = SL::DB::Manager::DeliveryTerm->get_all_sorted();
 
1595   $self->{current_employee_id}        = SL::DB::Manager::Employee->current->id;
 
1596   $self->{periodic_invoices_status}   = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
 
1597   $self->{order_probabilities}        = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
 
1598   $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
 
1600   my $print_form = Form->new('');
 
1601   $print_form->{type}      = $self->type;
 
1602   $print_form->{printers}  = SL::DB::Manager::Printer->get_all_sorted;
 
1603   $print_form->{languages} = SL::DB::Manager::Language->get_all_sorted;
 
1604   $self->{print_options}   = SL::Helper::PrintOptions->get_print_options(
 
1605     form => $print_form,
 
1606     options => {dialog_name_prefix => 'print_options.',
 
1610                 no_opendocument    => 0,
 
1614   foreach my $item (@{$self->order->orderitems}) {
 
1615     my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
 
1616     $item->active_price_source(   $price_source->price_from_source(   $item->active_price_source   ));
 
1617     $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
 
1620   if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
 
1621     # calculate shipped qtys here to prevent calling calculate for every item via the items method
 
1622     SL::Helper::ShippedQty->new->calculate($self->order)->write_to_objects;
 
1625   if ($self->order->number && $::instance_conf->get_webdav) {
 
1626     my $webdav = SL::Webdav->new(
 
1627       type     => $self->type,
 
1628       number   => $self->order->number,
 
1630     my @all_objects = $webdav->get_all_objects;
 
1631     @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
 
1633                                                     link => File::Spec->catfile($_->full_filedescriptor),
 
1637   $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
 
1639   $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery edit_periodic_invoices_config calculate_qty);
 
1640   $self->setup_edit_action_bar;
 
1643 sub setup_edit_action_bar {
 
1644   my ($self, %params) = @_;
 
1646   my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
 
1647                       || (($self->type eq sales_order_type())    && $::instance_conf->get_sales_order_show_delete)
 
1648                       || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
 
1650   for my $bar ($::request->layout->get('actionbar')) {
 
1655           call      => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
 
1656                                                     $::instance_conf->get_order_warn_no_deliverydate,
 
1658           checks    => [ 'kivi.Order.check_save_active_periodic_invoices' ],
 
1662           call      => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
 
1663           checks    => [ 'kivi.Order.check_save_active_periodic_invoices' ],
 
1664           disabled  => !$self->order->id ? t8('This object has not been saved yet.') : undef,
 
1666       ], # end of combobox "Save"
 
1673           t8('Save and Sales Order'),
 
1674           submit   => [ '#order_form', { action => "Order/sales_order" } ],
 
1675           only_if  => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
 
1676           disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
 
1679           t8('Save and Purchase Order'),
 
1680           submit   => [ '#order_form', { action => "Order/purchase_order" } ],
 
1681           only_if  => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
 
1682           disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
 
1685           t8('Save and Delivery Order'),
 
1686           call      => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
 
1687                                                                        $::instance_conf->get_order_warn_no_deliverydate,
 
1689           checks    => [ 'kivi.Order.check_save_active_periodic_invoices' ],
 
1690           only_if   => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
 
1693           t8('Save and Invoice'),
 
1694           call      => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
 
1695           checks    => [ 'kivi.Order.check_save_active_periodic_invoices' ],
 
1697       ], # end of combobox "Workflow"
 
1704           t8('Save and print'),
 
1705           call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts ],
 
1708           t8('Save and E-mail'),
 
1709           call => [ 'kivi.Order.email', $::instance_conf->get_order_warn_duplicate_parts ],
 
1712           t8('Download attachments of all parts'),
 
1713           call     => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
 
1714           disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
 
1715           only_if  => $::instance_conf->get_doc_storage,
 
1717       ], # end of combobox "Export"
 
1721         call     => [ 'kivi.Order.delete_order' ],
 
1722         confirm  => $::locale->text('Do you really want to delete this object?'),
 
1723         disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
 
1724         only_if  => $deletion_allowed,
 
1731   my ($order, $pdf_ref, $params) = @_;
 
1735   my $print_form = Form->new('');
 
1736   $print_form->{type}        = $order->type;
 
1737   $print_form->{formname}    = $params->{formname} || $order->type;
 
1738   $print_form->{format}      = $params->{format}   || 'pdf';
 
1739   $print_form->{media}       = $params->{media}    || 'file';
 
1740   $print_form->{groupitems}  = $params->{groupitems};
 
1741   $print_form->{media}       = 'file'                             if $print_form->{media} eq 'screen';
 
1743   $order->language($params->{language});
 
1744   $order->flatten_to_form($print_form, format_amounts => 1);
 
1748   if ($print_form->{format} =~ /(opendocument|oasis)/i) {
 
1749     $template_ext  = 'odt';
 
1750     $template_type = 'OpenDocument';
 
1753   # search for the template
 
1754   my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
 
1755     name        => $print_form->{formname},
 
1756     extension   => $template_ext,
 
1757     email       => $print_form->{media} eq 'email',
 
1758     language    => $params->{language},
 
1759     printer_id  => $print_form->{printer_id},  # todo
 
1762   if (!defined $template_file) {
 
1763     push @errors, $::locale->text('Cannot find matching template for this print request. Please contact your template maintainer. I tried these: #1.', join ', ', map { "'$_'"} @template_files);
 
1766   return @errors if scalar @errors;
 
1768   $print_form->throw_on_error(sub {
 
1770       $print_form->prepare_for_printing;
 
1772       $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
 
1773         format        => $print_form->{format},
 
1774         template_type => $template_type,
 
1775         template      => $template_file,
 
1776         variables     => $print_form,
 
1777         variable_content_types => {
 
1778           longdescription => 'html',
 
1779           partnotes       => 'html',
 
1784     } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
 
1790 sub get_files_for_email_dialog {
 
1793   my %files = map { ($_ => []) } qw(versions files vc_files part_files);
 
1795   return %files if !$::instance_conf->get_doc_storage;
 
1797   if ($self->order->id) {
 
1798     $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id,              object_type => $self->order->type, file_type => 'document') ];
 
1799     $files{files}    = [ SL::File->get_all(         object_id => $self->order->id,              object_type => $self->order->type, file_type => 'attachment') ];
 
1800     $files{vc_files} = [ SL::File->get_all(         object_id => $self->order->{$self->cv}->id, object_type => $self->cv,          file_type => 'attachment') ];
 
1804     uniq_by { $_->{id} }
 
1806       +{ id         => $_->part->id,
 
1807          partnumber => $_->part->partnumber }
 
1808     } @{$self->order->items_sorted};
 
1810   foreach my $part (@parts) {
 
1811     my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
 
1812     push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
 
1815   foreach my $key (keys %files) {
 
1816     $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
 
1822 sub make_periodic_invoices_config_from_yaml {
 
1823   my ($yaml_config) = @_;
 
1825   return if !$yaml_config;
 
1826   my $attr = SL::YAML::Load($yaml_config);
 
1827   return if 'HASH' ne ref $attr;
 
1828   return SL::DB::PeriodicInvoicesConfig->new(%$attr);
 
1832 sub get_periodic_invoices_status {
 
1833   my ($self, $config) = @_;
 
1835   return                      if $self->type ne sales_order_type();
 
1836   return t8('not configured') if !$config;
 
1838   my $active = ('HASH' eq ref $config)                           ? $config->{active}
 
1839              : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
 
1840              :                                                     die "Cannot get status of periodic invoices config";
 
1842   return $active ? t8('active') : t8('inactive');
 
1846   my ($self, $action) = @_;
 
1848   return '' if none { lc($action)} qw(add edit);
 
1851   # $::locale->text("Add Sales Order");
 
1852   # $::locale->text("Add Purchase Order");
 
1853   # $::locale->text("Add Quotation");
 
1854   # $::locale->text("Add Request for Quotation");
 
1855   # $::locale->text("Edit Sales Order");
 
1856   # $::locale->text("Edit Purchase Order");
 
1857   # $::locale->text("Edit Quotation");
 
1858   # $::locale->text("Edit Request for Quotation");
 
1860   $action = ucfirst(lc($action));
 
1861   return $self->type eq sales_order_type()       ? $::locale->text("$action Sales Order")
 
1862        : $self->type eq purchase_order_type()    ? $::locale->text("$action Purchase Order")
 
1863        : $self->type eq sales_quotation_type()   ? $::locale->text("$action Quotation")
 
1864        : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
 
1868 sub get_item_cvpartnumber {
 
1869   my ($self, $item) = @_;
 
1871   if ($self->cv eq 'vendor') {
 
1872     my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
 
1873     $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
 
1874   } elsif ($self->cv eq 'customer') {
 
1875     my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
 
1876     $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
 
1880 sub sales_order_type {
 
1884 sub purchase_order_type {
 
1888 sub sales_quotation_type {
 
1892 sub request_quotation_type {
 
1893   'request_quotation';
 
1897   return $_[0]->type eq sales_order_type()       ? 'ordnumber'
 
1898        : $_[0]->type eq purchase_order_type()    ? 'ordnumber'
 
1899        : $_[0]->type eq sales_quotation_type()   ? 'quonumber'
 
1900        : $_[0]->type eq request_quotation_type() ? 'quonumber'
 
1912 SL::Controller::Order - controller for orders
 
1916 This is a new form to enter orders, completely rewritten with the use
 
1917 of controller and java script techniques.
 
1919 The aim is to provide the user a better expirience and a faster flow
 
1920 of work. Also the code should be more readable, more reliable and
 
1929 One input row, so that input happens every time at the same place.
 
1933 Use of pickers where possible.
 
1937 Possibility to enter more than one item at once.
 
1941 Item list in a scrollable area, so that the workflow buttons stay at
 
1946 Reordering item rows with drag and drop is possible. Sorting item rows is
 
1947 possible (by partnumber, description, qty, sellprice and discount for now).
 
1951 No C<update> is necessary. All entries and calculations are managed
 
1952 with ajax-calls and the page does only reload on C<save>.
 
1956 User can see changes immediately, because of the use of java script
 
1967 =item * C<SL/Controller/Order.pm>
 
1971 =item * C<template/webpages/order/form.html>
 
1975 =item * C<template/webpages/order/tabs/basic_data.html>
 
1977 Main tab for basic_data.
 
1979 This is the only tab here for now. "linked records" and "webdav" tabs are
 
1980 reused from generic code.
 
1984 =item * C<template/webpages/order/tabs/_business_info_row.html>
 
1986 For displaying information on business type
 
1988 =item * C<template/webpages/order/tabs/_item_input.html>
 
1990 The input line for items
 
1992 =item * C<template/webpages/order/tabs/_row.html>
 
1994 One row for already entered items
 
1996 =item * C<template/webpages/order/tabs/_tax_row.html>
 
1998 Displaying tax information
 
2000 =item * C<template/webpages/order/tabs/_multi_items_dialog.html>
 
2002 Dialog for entering more than one item at once
 
2004 =item * C<template/webpages/order/tabs/_multi_items_result.html>
 
2006 Results for the filter in the multi items dialog
 
2008 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
 
2010 Dialog for selecting price and discount sources
 
2014 =item * C<js/kivi.Order.js>
 
2016 java script functions
 
2028 =item * credit limit
 
2030 =item * more workflows (quotation, rfq)
 
2032 =item * price sources: little symbols showing better price / better discount
 
2034 =item * select units in input row?
 
2036 =item * custom shipto address
 
2038 =item * check for direct delivery (workflow sales order -> purchase order)
 
2040 =item * language / part translations
 
2042 =item * access rights
 
2044 =item * display weights
 
2050 =item * optional client/user behaviour
 
2052 (transactions has to be set - department has to be set -
 
2053  force project if enabled in client config - transport cost reminder)
 
2057 =head1 KNOWN BUGS AND CAVEATS
 
2063 Customer discount is not displayed as a valid discount in price source popup
 
2064 (this might be a bug in price sources)
 
2066 (I cannot reproduce this (Bernd))
 
2070 No indication that <shift>-up/down expands/collapses second row.
 
2074 Inline creation of parts is not currently supported
 
2078 Table header is not sticky in the scrolling area.
 
2082 Sorting does not include C<position>, neither does reordering.
 
2084 This behavior was implemented intentionally. But we can discuss, which behavior
 
2085 should be implemented.
 
2089 C<show_multi_items_dialog> does not use the currently inserted string for
 
2094 The language selected in print or email dialog is not saved when the order is saved.
 
2098 =head1 To discuss / Nice to have
 
2104 How to expand/collapse second row. Now it can be done clicking the icon or
 
2109 Possibility to change longdescription in input row?
 
2113 Possibility to select PriceSources in input row?
 
2117 This controller uses a (changed) copy of the template for the PriceSource
 
2118 dialog. Maybe there could be used one code source.
 
2122 Rounding-differences between this controller (PriceTaxCalculator) and the old
 
2123 form. This is not only a problem here, but also in all parts using the PTC.
 
2124 There exists a ticket and a patch. This patch should be testet.
 
2128 An indicator, if the actual inputs are saved (like in an
 
2129 editor or on text processing application).
 
2133 A warning when leaving the page without saveing unchanged inputs.
 
2140 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>