Merge pull request #37 from kivitendo/2021-delivery-order-controller-8
authorJan Büren <jan@kivitendo-premium.de>
Mon, 14 Feb 2022 14:10:29 +0000 (15:10 +0100)
committerGitHub <noreply@github.com>
Mon, 14 Feb 2022 14:10:29 +0000 (15:10 +0100)
2021 delivery order controller 8

46 files changed:
SL/Controller/Base.pm
SL/Controller/CsvImport/DeliveryOrder.pm
SL/Controller/DeliveryOrder.pm [new file with mode: 0644]
SL/Controller/DeliveryOrder/TypeData.pm [new file with mode: 0644]
SL/Controller/Order.pm
SL/Controller/RecordLinks.pm
SL/DB/DeliveryOrder.pm
SL/DB/DeliveryOrder/TypeData.pm [new file with mode: 0644]
SL/DB/DeliveryOrderItem.pm
SL/DB/DeliveryOrderItemsStock.pm
SL/DB/Helper/TransNumberGenerator.pm
SL/DB/Inventory.pm
SL/DB/Manager/DeliveryOrder.pm
SL/DB/MetaSetup/Default.pm
SL/DB/MetaSetup/DeliveryOrder.pm
SL/DO.pm
SL/Dev/Record.pm
SL/Form.pm
SL/Helper/Number.pm
SL/Helper/PrintOptions.pm
SL/Presenter/DeliveryOrder.pm
SL/Presenter/Record.pm
SL/TransNumber.pm
SL/Webdav.pm
bin/mozilla/do.pl
bin/mozilla/io.pl
js/kivi.DeliveryOrder.js [new file with mode: 0644]
js/kivi.js
js/locale/de.js
locale/de/all
menus/user/00-erp.yaml
sql/Pg-upgrade2/deliveryorder_transnumbers.sql [new file with mode: 0644]
sql/Pg-upgrade2/deliveryorder_type.sql [new file with mode: 0644]
t/db/delivery_order.t [new file with mode: 0644]
t/db_helper/record_links.t
templates/print/marei/deutsch.tex
templates/print/marei/english.tex
templates/print/marei/purchase_delivery_order.tex
templates/print/marei/supplier_delivery_order.tex [new symlink]
templates/webpages/delivery_order/form.html [new file with mode: 0644]
templates/webpages/delivery_order/stock_dialog.html [new file with mode: 0644]
templates/webpages/delivery_order/tabs/_business_info_row.html [new file with mode: 0644]
templates/webpages/delivery_order/tabs/_item_input.html [new file with mode: 0644]
templates/webpages/delivery_order/tabs/_row.html [new file with mode: 0644]
templates/webpages/delivery_order/tabs/_second_row.html [new file with mode: 0644]
templates/webpages/delivery_order/tabs/basic_data.html [new file with mode: 0644]

index 5e0fd26..63f153f 100644 (file)
@@ -15,7 +15,7 @@ use SL::Presenter;
 use Rose::Object::MakeMethods::Generic
 (
   scalar                  => [ qw(action_name) ],
-  'scalar --get_set_init' => [ qw(js) ],
+  'scalar --get_set_init' => [ qw(js p) ],
 );
 
 #
@@ -184,6 +184,10 @@ sub presenter {
   return SL::Presenter->get;
 }
 
+sub init_p {
+  return SL::Presenter->get;
+}
+
 sub controller_name {
   my $class = ref($_[0]) || $_[0];
   $class    =~ s/^SL::Controller:://;
index b93455a..2e5a9e9 100644 (file)
@@ -9,6 +9,7 @@ use DateTime;
 
 use SL::Controller::CsvImport::Helper::Consistency;
 use SL::DB::DeliveryOrder;
+use SL::DB::DeliveryOrder::TypeData qw(:types);
 use SL::DB::DeliveryOrderItem;
 use SL::DB::DeliveryOrderItemsStock;
 use SL::DB::Part;
@@ -215,10 +216,10 @@ sub setup_displayable_columns {
                                  { name => 'globalprojectnumber',     description => $::locale->text('Document Project (number)')             },
                                  { name => 'globalproject_id',        description => $::locale->text('Document Project (database ID)')        },
                                  { name => 'intnotes',                description => $::locale->text('Internal Notes')                        },
-                                 { name => 'is_sales',                description => $::locale->text('Is sales')                              },
                                  { name => 'language',                description => $::locale->text('Language (name)')                       },
                                  { name => 'language_id',             description => $::locale->text('Language (database ID)')                },
                                  { name => 'notes',                   description => $::locale->text('Notes')                                 },
+                                 { name => 'order_type',              description => $::locale->text('Delivery Order Type')                   },
                                  { name => 'ordnumber',               description => $::locale->text('Order Number')                          },
                                  { name => 'payment',                 description => $::locale->text('Payment terms (name)')                  },
                                  { name => 'payment_id',              description => $::locale->text('Payment terms (database ID)')           },
@@ -553,7 +554,7 @@ sub handle_order {
     push @{ $entry->{errors} }, $::locale->text('Error: Customer/vendor missing');
   }
 
-  $self->handle_is_sales($entry);
+  $self->handle_type($entry);
   $self->check_contact($entry);
   $self->check_language($entry);
   $self->check_payment($entry);
@@ -685,11 +686,19 @@ sub handle_stock {
   }
 }
 
-sub handle_is_sales {
+sub handle_type {
   my ($self, $entry) = @_;
 
-  if (!exists $entry->{raw_data}->{is_sales}) {
-    $entry->{object}->is_sales(!!$entry->{object}->customer_id);
+  if (!exists $entry->{raw_data}->{order_type}) {
+    # if no type is present - set to sales delivery order or purchase delivery
+    # order depending on is_sales or customer/vendor
+
+    $entry->{object}->order_type(
+      $entry->{object}->customer_id  ? SALES_DELIVERY_ORDER_TYPE :
+      $entry->{object}->vendor_id    ? PURCHASE_DELIVERY_ORDER_TYPE :
+      $entry->{raw_data}->{is_sales} ? SALES_DELIVERY_ORDER_TYPE :
+                                       PURCHASE_DELIVERY_ORDER_TYPE
+    );
   }
 }
 
diff --git a/SL/Controller/DeliveryOrder.pm b/SL/Controller/DeliveryOrder.pm
new file mode 100644 (file)
index 0000000..c96bc42
--- /dev/null
@@ -0,0 +1,2383 @@
+package SL::Controller::DeliveryOrder;
+
+use strict;
+use parent qw(SL::Controller::Base);
+
+use SL::Helper::Flash qw(flash_later);
+use SL::Helper::Number qw(_format_number _parse_number);
+use SL::Presenter::Tag qw(select_tag hidden_tag div_tag);
+use SL::Presenter::DeliveryOrder qw(delivery_order_status_line);
+use SL::Locale::String qw(t8);
+use SL::SessionFile::Random;
+use SL::PriceSource;
+use SL::Webdav;
+use SL::File;
+use SL::MIME;
+use SL::Util qw(trim);
+use SL::YAML;
+use SL::DB::History;
+use SL::DB::Order;
+use SL::DB::Default;
+use SL::DB::Unit;
+use SL::DB::Order;
+use SL::DB::Part;
+use SL::DB::PartClassification;
+use SL::DB::PartsGroup;
+use SL::DB::Printer;
+use SL::DB::Language;
+use SL::DB::RecordLink;
+use SL::DB::Shipto;
+use SL::DB::Translation;
+use SL::DB::TransferType;
+
+use SL::Helper::CreatePDF qw(:all);
+use SL::Helper::PrintOptions;
+use SL::Helper::ShippedQty;
+use SL::Helper::UserPreferences::PositionsScrollbar;
+use SL::Helper::UserPreferences::UpdatePositions;
+
+use SL::Controller::Helper::GetModels;
+use SL::Controller::DeliveryOrder::TypeData qw(:types);
+
+use List::Util qw(first sum0);
+use List::UtilsBy qw(sort_by uniq_by);
+use List::MoreUtils qw(any none pairwise first_index);
+use English qw(-no_match_vars);
+use File::Spec;
+use Cwd;
+use Sort::Naturally;
+
+use Rose::Object::MakeMethods::Generic
+(
+ scalar => [ qw(item_ids_to_delete is_custom_shipto_to_delete) ],
+ 'scalar --get_set_init' => [ qw(order valid_types type cv p all_price_factors search_cvpartnumber show_update_button part_picker_classification_ids type_data) ],
+);
+
+
+# safety
+__PACKAGE__->run_before('check_auth',
+                        except => [ qw(update_stock_information) ]);
+
+__PACKAGE__->run_before('get_unalterable_data',
+                        only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
+                                     print send_email) ]);
+
+#
+# actions
+#
+
+# add a new order
+sub action_add {
+  my ($self) = @_;
+
+  $self->order->transdate(DateTime->now_local());
+  $self->type_data->set_reqdate_by_type;
+
+
+  $self->pre_render();
+  $self->render(
+    'delivery_order/form',
+    title => $self->get_title_for('add'),
+    %{$self->{template_args}}
+  );
+}
+
+sub action_add_from_order {
+  my ($self) = @_;
+  # this interfers with init_order
+  $self->{converted_from_oe_id} = delete $::form->{id};
+
+  $self->type_data->validate($::form->{type});
+
+  my $order = SL::DB::Order->new(id => $self->{converted_from_oe_id})->load;
+
+  $self->order(SL::DB::DeliveryOrder->new_from($order, type => $::form->{type}));
+
+  $self->action_add;
+}
+
+# edit an existing order
+sub action_edit {
+  my ($self) = @_;
+
+  if ($::form->{id}) {
+    $self->load_order;
+
+  } else {
+    # this is to edit an order from an unsaved order object
+
+    # set item ids to new fake id, to identify them as new items
+    foreach my $item (@{$self->order->items_sorted}) {
+      $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
+    }
+    # trigger rendering values for second row as hidden, because they
+    # are loaded only on demand. So we need to keep the values from
+    # the source.
+    $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
+  }
+
+  $self->pre_render();
+  $self->render(
+    'delivery_order/form',
+    title => $self->get_title_for('edit'),
+    %{$self->{template_args}}
+  );
+}
+
+# edit a collective order (consisting of one or more existing orders)
+sub action_edit_collective {
+  my ($self) = @_;
+
+  # collect order ids
+  my @multi_ids = map {
+    $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
+  } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
+
+  # fall back to add if no ids are given
+  if (scalar @multi_ids == 0) {
+    $self->action_add();
+    return;
+  }
+
+  # fall back to save as new if only one id is given
+  if (scalar @multi_ids == 1) {
+    $self->order(SL::DB::DeliveryOrder->new(id => $multi_ids[0])->load);
+    $self->action_save_as_new();
+    return;
+  }
+
+  # make new order from given orders
+  my @multi_orders = map { SL::DB::DeliveryOrder->new(id => $_)->load } @multi_ids;
+  $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
+  $self->order(SL::DB::DeliveryOrder->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
+
+  $self->action_edit();
+}
+
+# delete the order
+sub action_delete {
+  my ($self) = @_;
+
+  my $errors = $self->delete();
+
+  if (scalar @{ $errors }) {
+    $self->js->flash('error', $_) foreach @{ $errors };
+    return $self->js->render();
+  }
+
+  flash_later('info', $self->type_data->text("delete"));
+
+  my @redirect_params = (
+    action => 'add',
+    type   => $self->type,
+  );
+
+  $self->redirect_to(@redirect_params);
+}
+
+# save the order
+sub action_save {
+  my ($self) = @_;
+
+  my $errors = $self->save();
+
+  if (scalar @{ $errors }) {
+    $self->js->flash('error', $_) foreach @{ $errors };
+    return $self->js->render();
+  }
+
+  flash_later('info', $self->type_data->text("saved"));
+
+  my @redirect_params = (
+    action => 'edit',
+    type   => $self->type,
+    id     => $self->order->id,
+  );
+
+  $self->redirect_to(@redirect_params);
+}
+
+# save the order as new document an open it for edit
+sub action_save_as_new {
+  my ($self) = @_;
+
+  my $order = $self->order;
+
+  if (!$order->id) {
+    $self->js->flash('error', t8('This object has not been saved yet.'));
+    return $self->js->render();
+  }
+
+  # load order from db to check if values changed
+  my $saved_order = SL::DB::DeliveryOrder->new(id => $order->id)->load;
+
+  my %new_attrs;
+  # Lets assign a new number if the user hasn't changed the previous one.
+  # If it has been changed manually then use it as-is.
+  $new_attrs{number}    = (trim($order->number) eq $saved_order->number)
+                        ? ''
+                        : trim($order->number);
+
+  # Clear transdate unless changed
+  $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
+                        ? DateTime->today_local
+                        : $order->transdate;
+
+  # Set new reqdate unless changed if it is enabled in client config
+  $new_attrs{reqdate} = $self->type_data->get_reqdate_by_type($order->reqdate, $saved_order->reqdate);
+
+  # Update employee
+  $new_attrs{employee}  = SL::DB::Manager::Employee->current;
+
+  # Create new record from current one
+  $self->order(SL::DB::DeliveryOrder->new_from($order, destination_type => $order->type, attributes => \%new_attrs));
+
+  # no linked records on save as new
+  delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
+
+  # save
+  $self->action_save();
+}
+
+# print the order
+#
+# This is called if "print" is pressed in the print dialog.
+# If PDF creation was requested and succeeded, the pdf is offered for download
+# via send_file (which uses ajax in this case).
+sub action_print {
+  my ($self) = @_;
+
+  my $errors = $self->save();
+
+  if (scalar @{ $errors }) {
+    $self->js->flash('error', $_) foreach @{ $errors };
+    return $self->js->render();
+  }
+
+  $self->js_reset_order_and_item_ids_after_save;
+
+  my $format      = $::form->{print_options}->{format};
+  my $media       = $::form->{print_options}->{media};
+  my $formname    = $::form->{print_options}->{formname};
+  my $copies      = $::form->{print_options}->{copies};
+  my $groupitems  = $::form->{print_options}->{groupitems};
+  my $printer_id  = $::form->{print_options}->{printer_id};
+
+  # only pdf and opendocument by now
+  if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf)) {
+    return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
+  }
+
+  # only screen or printer by now
+  if (none { $media eq $_ } qw(screen printer)) {
+    return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
+  }
+
+  # create a form for generate_attachment_filename
+  my $form   = Form->new;
+  $form->{$self->nr_key()}  = $self->order->number;
+  $form->{type}             = $self->type;
+  $form->{format}           = $format;
+  $form->{formname}         = $formname;
+  $form->{language}         = '_' . $self->order->language->template_code if $self->order->language;
+  my $pdf_filename          = $form->generate_attachment_filename();
+
+  my $pdf;
+  my @errors = generate_pdf($self->order, \$pdf, { format     => $format,
+                                                   formname   => $formname,
+                                                   language   => $self->order->language,
+                                                   printer_id => $printer_id,
+                                                   groupitems => $groupitems });
+  if (scalar @errors) {
+    return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
+  }
+
+  if ($media eq 'screen') {
+    # screen/download
+    $self->js->flash('info', t8('The PDF has been created'));
+    $self->send_file(
+      \$pdf,
+      type         => SL::MIME->mime_type_from_ext($pdf_filename),
+      name         => $pdf_filename,
+      js_no_render => 1,
+    );
+
+  } elsif ($media eq 'printer') {
+    # printer
+    my $printer_id = $::form->{print_options}->{printer_id};
+    SL::DB::Printer->new(id => $printer_id)->load->print_document(
+      copies  => $copies,
+      content => $pdf,
+    );
+
+    $self->js->flash('info', t8('The PDF has been printed'));
+  }
+
+  my @warnings = store_pdf_to_webdav_and_filemanagement($self->order, $pdf, $pdf_filename);
+  if (scalar @warnings) {
+    $self->js->flash('warning', $_) for @warnings;
+  }
+
+  $self->save_history('PRINTED');
+
+  $self->js
+    ->run('kivi.ActionBar.setEnabled', '#save_and_email_action')
+    ->render;
+}
+sub action_preview_pdf {
+  my ($self) = @_;
+
+  my $errors = $self->save();
+  if (scalar @{ $errors }) {
+    $self->js->flash('error', $_) foreach @{ $errors };
+    return $self->js->render();
+  }
+
+  $self->js_reset_order_and_item_ids_after_save;
+
+  my $format      = 'pdf';
+  my $media       = 'screen';
+  my $formname    = $self->type;
+
+  # only pdf
+  # create a form for generate_attachment_filename
+  my $form   = Form->new;
+  $form->{$self->nr_key()}  = $self->order->number;
+  $form->{type}             = $self->type;
+  $form->{format}           = $format;
+  $form->{formname}         = $formname;
+  $form->{language}         = '_' . $self->order->language->template_code if $self->order->language;
+  my $pdf_filename          = $form->generate_attachment_filename();
+
+  my $pdf;
+  my @errors = generate_pdf($self->order, \$pdf, { format     => $format,
+                                                   formname   => $formname,
+                                                   language   => $self->order->language,
+                                                 });
+  if (scalar @errors) {
+    return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
+  }
+  $self->save_history('PREVIEWED');
+  $self->js->flash('info', t8('The PDF has been previewed'));
+  # screen/download
+  $self->send_file(
+    \$pdf,
+    type         => SL::MIME->mime_type_from_ext($pdf_filename),
+    name         => $pdf_filename,
+    js_no_render => 0,
+  );
+}
+
+# open the email dialog
+sub action_save_and_show_email_dialog {
+  my ($self) = @_;
+
+  my $errors = $self->save();
+
+  if (scalar @{ $errors }) {
+    $self->js->flash('error', $_) foreach @{ $errors };
+    return $self->js->render();
+  }
+
+  my $cv_method = $self->cv;
+
+  if (!$self->order->$cv_method) {
+    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'))
+                    ->render($self);
+  }
+
+  my $email_form;
+  $email_form->{to}   = $self->order->contact->cp_email if $self->order->contact;
+  $email_form->{to} ||= $self->order->$cv_method->email;
+  $email_form->{cc}   = $self->order->$cv_method->cc;
+  $email_form->{bcc}  = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
+  # Todo: get addresses from shipto, if any
+
+  my $form = Form->new;
+  $form->{$self->nr_key()}  = $self->order->number;
+  $form->{cusordnumber}     = $self->order->cusordnumber;
+  $form->{formname}         = $self->type;
+  $form->{type}             = $self->type;
+  $form->{language}         = '_' . $self->order->language->template_code if $self->order->language;
+  $form->{language_id}      = $self->order->language->id                  if $self->order->language;
+  $form->{format}           = 'pdf';
+  $form->{cp_id}            = $self->order->contact->cp_id if $self->order->contact;
+
+  $email_form->{subject}             = $form->generate_email_subject();
+  $email_form->{attachment_filename} = $form->generate_attachment_filename();
+  $email_form->{message}             = $form->generate_email_body();
+  $email_form->{js_send_function}    = 'kivi.DeliveryOrder.send_email()';
+
+  my %files = $self->get_files_for_email_dialog();
+  $self->{all_employees} = SL::DB::Manager::Employee->get_all(query => [ deleted => 0 ]);
+  my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
+                                  email_form  => $email_form,
+                                  show_bcc    => $::auth->assert('email_bcc', 'may fail'),
+                                  FILES       => \%files,
+                                  is_customer => $self->type_data->is_customer,
+                                  ALL_EMPLOYEES => $self->{all_employees},
+  );
+
+  $self->js
+      ->run('kivi.DeliveryOrder.show_email_dialog', $dialog_html)
+      ->reinit_widgets
+      ->render($self);
+}
+
+# send email
+#
+# Todo: handling error messages: flash is not displayed in dialog, but in the main form
+sub action_send_email {
+  my ($self) = @_;
+
+  my $errors = $self->save();
+
+  if (scalar @{ $errors }) {
+    $self->js->run('kivi.DeliveryOrder.close_email_dialog');
+    $self->js->flash('error', $_) foreach @{ $errors };
+    return $self->js->render();
+  }
+
+  $self->js_reset_order_and_item_ids_after_save;
+
+  my $email_form  = delete $::form->{email_form};
+  my %field_names = (to => 'email');
+
+  $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
+
+  # for Form::cleanup which may be called in Form::send_email
+  $::form->{cwd}    = getcwd();
+  $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
+
+  $::form->{$_}     = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
+  $::form->{media}  = 'email';
+
+  if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
+    my $pdf;
+    my @errors = generate_pdf($self->order, \$pdf, {media      => $::form->{media},
+                                                    format     => $::form->{print_options}->{format},
+                                                    formname   => $::form->{print_options}->{formname},
+                                                    language   => $self->order->language,
+                                                    printer_id => $::form->{print_options}->{printer_id},
+                                                    groupitems => $::form->{print_options}->{groupitems}});
+    if (scalar @errors) {
+      return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
+    }
+
+    my @warnings = store_pdf_to_webdav_and_filemanagement($self->order, $pdf, $::form->{attachment_filename});
+    if (scalar @warnings) {
+      flash_later('warning', $_) for @warnings;
+    }
+
+    my $sfile = SL::SessionFile::Random->new(mode => "w");
+    $sfile->fh->print($pdf);
+    $sfile->fh->close;
+
+    $::form->{tmpfile} = $sfile->file_name;
+    $::form->{tmpdir}  = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
+  }
+
+  $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail
+  $::form->send_email(\%::myconfig, 'pdf');
+
+  # internal notes
+  my $intnotes = $self->order->intnotes;
+  $intnotes   .= "\n\n" if $self->order->intnotes;
+  $intnotes   .= t8('[email]')                                                                                        . "\n";
+  $intnotes   .= t8('Date')       . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
+  $intnotes   .= t8('To (email)') . ": " . $::form->{email}                                                           . "\n";
+  $intnotes   .= t8('Cc')         . ": " . $::form->{cc}                                                              . "\n"    if $::form->{cc};
+  $intnotes   .= t8('Bcc')        . ": " . $::form->{bcc}                                                             . "\n"    if $::form->{bcc};
+  $intnotes   .= t8('Subject')    . ": " . $::form->{subject}                                                         . "\n\n";
+  $intnotes   .= t8('Message')    . ": " . $::form->{message};
+
+  $self->order->update_attributes(intnotes => $intnotes);
+
+  $self->save_history('MAILED');
+
+  flash_later('info', t8('The email has been sent.'));
+
+  my @redirect_params = (
+    action => 'edit',
+    type   => $self->type,
+    id     => $self->order->id,
+  );
+
+  $self->redirect_to(@redirect_params);
+}
+
+# save the order and redirect to the frontend subroutine for a new
+# delivery order
+sub action_save_and_delivery_order {
+  my ($self) = @_;
+
+  $self->save_and_redirect_to(
+    controller => 'oe.pl',
+    action     => 'oe_delivery_order_from_order',
+  );
+}
+
+# save the order and redirect to the frontend subroutine for a new
+# invoice
+sub action_save_and_invoice {
+  my ($self) = @_;
+
+  $self->save_and_redirect_to(
+    controller => 'oe.pl',
+    action     => 'oe_invoice_from_order',
+  );
+}
+
+# workflow from sales order to sales quotation
+sub action_sales_quotation {
+  $_[0]->workflow_sales_or_request_for_quotation();
+}
+
+# workflow from sales order to sales quotation
+sub action_request_for_quotation {
+  $_[0]->workflow_sales_or_request_for_quotation();
+}
+
+# workflow from sales quotation to sales order
+sub action_sales_order {
+  $_[0]->workflow_sales_or_purchase_order();
+}
+
+# workflow from rfq to purchase order
+sub action_purchase_order {
+  $_[0]->workflow_sales_or_purchase_order();
+}
+
+# workflow from purchase order to ap transaction
+sub action_save_and_ap_transaction {
+  my ($self) = @_;
+
+  $self->save_and_redirect_to(
+    controller => 'ap.pl',
+    action     => 'add_from_purchase_order',
+  );
+}
+
+# set form elements in respect to a changed customer or vendor
+#
+# This action is called on an change of the customer/vendor picker.
+sub action_customer_vendor_changed {
+  my ($self) = @_;
+
+  setup_order_from_cv($self->order);
+
+  my $cv_method = $self->cv;
+
+  if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
+    $self->js->show('#cp_row');
+  } else {
+    $self->js->hide('#cp_row');
+  }
+
+  if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
+    $self->js->show('#shipto_selection');
+  } else {
+    $self->js->hide('#shipto_selection');
+  }
+
+  $self->js->val( '#order_salesman_id',      $self->order->salesman_id)        if $self->order->is_sales;
+
+  $self->js
+    ->replaceWith('#order_cp_id',            $self->build_contact_select)
+    ->replaceWith('#order_shipto_id',        $self->build_shipto_select)
+    ->replaceWith('#shipto_inputs  ',        $self->build_shipto_inputs)
+    ->replaceWith('#business_info_row',      $self->build_business_info_row)
+    ->val(        '#order_taxzone_id',       $self->order->taxzone_id)
+    ->val(        '#order_taxincluded',      $self->order->taxincluded)
+    ->val(        '#order_currency_id',      $self->order->currency_id)
+    ->val(        '#order_payment_id',       $self->order->payment_id)
+    ->val(        '#order_delivery_term_id', $self->order->delivery_term_id)
+    ->val(        '#order_intnotes',         $self->order->intnotes)
+    ->val(        '#order_language_id',      $self->order->$cv_method->language_id)
+    ->focus(      '#order_' . $self->cv . '_id')
+    ->run('kivi.DeliveryOrder.update_exchangerate');
+
+  $self->js_redisplay_cvpartnumbers;
+  $self->js->render();
+}
+
+# open the dialog for customer/vendor details
+sub action_show_customer_vendor_details_dialog {
+  my ($self) = @_;
+
+  my $is_customer = 'customer' eq $::form->{vc};
+  my $cv;
+  if ($is_customer) {
+    $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
+  } else {
+    $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
+  }
+
+  my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
+  $details{discount_as_percent} = $cv->discount_as_percent;
+  $details{creditlimt}          = $cv->creditlimit_as_number;
+  $details{business}            = $cv->business->description      if $cv->business;
+  $details{language}            = $cv->language_obj->description  if $cv->language_obj;
+  $details{delivery_terms}      = $cv->delivery_term->description if $cv->delivery_term;
+  $details{payment_terms}       = $cv->payment->description       if $cv->payment;
+  $details{pricegroup}          = $cv->pricegroup->pricegroup     if $is_customer && $cv->pricegroup;
+
+  foreach my $entry (@{ $cv->shipto }) {
+    push @{ $details{SHIPTO} },   { map { $_ => $entry->$_ } @{$entry->meta->columns} };
+  }
+  foreach my $entry (@{ $cv->contacts }) {
+    push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
+  }
+
+  $_[0]->render('common/show_vc_details', { layout => 0 },
+                is_customer => $is_customer,
+                %details);
+
+}
+
+# called if a unit in an existing item row is changed
+sub action_unit_changed {
+  my ($self) = @_;
+
+  my $idx  = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
+  my $item = $self->order->items_sorted->[$idx];
+
+  my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
+  $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
+
+  $self->js
+    ->run('kivi.DeliveryOrder.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
+  $self->js_redisplay_line_values;
+  $self->js->render();
+}
+
+# add an item row for a new item entered in the input row
+sub action_add_item {
+  my ($self) = @_;
+
+  delete $::form->{add_item}->{create_part_type};
+
+  my $form_attr = $::form->{add_item};
+
+  return unless $form_attr->{parts_id};
+
+  my $item = new_item($self->order, $form_attr);
+
+  $self->order->add_items($item);
+
+  $self->get_item_cvpartnumber($item);
+
+  my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
+  my $row_as_html = $self->p->render('delivery_order/tabs/_row',
+                                     ITEM => $item,
+                                     ID   => $item_id,
+                                     SELF => $self,
+                                     in_out => $self->type_data->transfer,
+  );
+
+  if ($::form->{insert_before_item_id}) {
+    $self->js
+      ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
+  } else {
+    $self->js
+      ->append('#row_table_id', $row_as_html);
+  }
+
+  if ( $item->part->is_assortment ) {
+    $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
+    foreach my $assortment_item ( @{$item->part->assortment_items} ) {
+      my $attr = { parts_id => $assortment_item->parts_id,
+                   qty      => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
+                   unit     => $assortment_item->unit,
+                   description => $assortment_item->part->description,
+                 };
+      my $item = new_item($self->order, $attr);
+
+      # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
+      $item->discount(1) unless $assortment_item->charge;
+
+      $self->order->add_items( $item );
+      $self->get_item_cvpartnumber($item);
+      my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
+      my $row_as_html = $self->p->render('delivery_order/tabs/_row',
+                                         ITEM => $item,
+                                         ID   => $item_id,
+                                         SELF => $self,
+      );
+      if ($::form->{insert_before_item_id}) {
+        $self->js
+          ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
+      } else {
+        $self->js
+          ->append('#row_table_id', $row_as_html);
+      }
+    };
+  };
+
+  $self->js
+    ->val('.add_item_input', '')
+    ->run('kivi.DeliveryOrder.init_row_handlers')
+    ->run('kivi.DeliveryOrder.renumber_positions')
+    ->focus('#add_item_parts_id_name');
+
+  $self->js->run('kivi.DeliveryOrder.row_table_scroll_down') if !$::form->{insert_before_item_id};
+
+  $self->js->render();
+}
+
+# add item rows for multiple items at once
+sub action_add_multi_items {
+  my ($self) = @_;
+
+  my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
+  return $self->js->render() unless scalar @form_attr;
+
+  my @items;
+  foreach my $attr (@form_attr) {
+    my $item = new_item($self->order, $attr);
+    push @items, $item;
+    if ( $item->part->is_assortment ) {
+      foreach my $assortment_item ( @{$item->part->assortment_items} ) {
+        my $attr = { parts_id => $assortment_item->parts_id,
+                     qty      => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
+                     unit     => $assortment_item->unit,
+                     description => $assortment_item->part->description,
+                   };
+        my $item = new_item($self->order, $attr);
+
+        # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
+        $item->discount(1) unless $assortment_item->charge;
+        push @items, $item;
+      }
+    }
+  }
+  $self->order->add_items(@items);
+
+  foreach my $item (@items) {
+    $self->get_item_cvpartnumber($item);
+    my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
+    my $row_as_html = $self->p->render('delivery_order/tabs/_row',
+                                       ITEM => $item,
+                                       ID   => $item_id,
+                                       SELF => $self,
+                                       in_out => $self->type_data->transfer,
+    );
+
+    if ($::form->{insert_before_item_id}) {
+      $self->js
+        ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
+    } else {
+      $self->js
+        ->append('#row_table_id', $row_as_html);
+    }
+  }
+
+  $self->js
+    ->run('kivi.Part.close_picker_dialogs')
+    ->run('kivi.DeliveryOrder.init_row_handlers')
+    ->run('kivi.DeliveryOrder.renumber_positions')
+    ->focus('#add_item_parts_id_name');
+
+  $self->js->run('kivi.DeliveryOrder.row_table_scroll_down') if !$::form->{insert_before_item_id};
+
+  $self->js->render();
+}
+
+sub action_update_exchangerate {
+  my ($self) = @_;
+
+  my $data = {
+    is_standard   => $self->order->currency_id == $::instance_conf->get_currency_id,
+    currency_name => $self->order->currency->name,
+  };
+
+  $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
+}
+
+# redisplay item rows if they are sorted by an attribute
+sub action_reorder_items {
+  my ($self) = @_;
+
+  my %sort_keys = (
+    partnumber   => sub { $_[0]->part->partnumber },
+    description  => sub { $_[0]->description },
+    qty          => sub { $_[0]->qty },
+    sellprice    => sub { $_[0]->sellprice },
+    discount     => sub { $_[0]->discount },
+    cvpartnumber => sub { $_[0]->{cvpartnumber} },
+  );
+
+  $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
+
+  my $method = $sort_keys{$::form->{order_by}};
+  my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
+  if ($::form->{sort_dir}) {
+    if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
+      @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
+    } else {
+      @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
+    }
+  } else {
+    if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
+      @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
+    } else {
+      @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
+    }
+  }
+  $self->js
+    ->run('kivi.DeliveryOrder.redisplay_items', \@to_sort)
+    ->render;
+}
+
+# show the popup to choose a price/discount source
+sub action_price_popup {
+  my ($self) = @_;
+
+  my $idx  = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
+  my $item = $self->order->items_sorted->[$idx];
+
+  $self->render_price_dialog($item);
+}
+
+# save the order in a session variable and redirect to the part controller
+sub action_create_part {
+  my ($self) = @_;
+
+  my $previousform = $::auth->save_form_in_session(non_scalars => 1);
+
+  my $callback     = $self->url_for(
+    action       => 'return_from_create_part',
+    type         => $self->type, # type is needed for check_auth on return
+    previousform => $previousform,
+  );
+
+  flash_later('info', t8('You are adding a new part while you are editing another document. You will be redirected to your document when saving the new part or aborting this form.'));
+
+  my @redirect_params = (
+    controller => 'Part',
+    action     => 'add',
+    part_type  => $::form->{add_item}->{create_part_type},
+    callback   => $callback,
+    show_abort => 1,
+  );
+
+  $self->redirect_to(@redirect_params);
+}
+
+sub action_return_from_create_part {
+  my ($self) = @_;
+
+  $self->{created_part} = SL::DB::Part->new(id => delete $::form->{new_parts_id})->load if $::form->{new_parts_id};
+
+  $::auth->restore_form_from_session(delete $::form->{previousform});
+
+  # set item ids to new fake id, to identify them as new items
+  foreach my $item (@{$self->order->items_sorted}) {
+    $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
+  }
+
+  $self->get_unalterable_data();
+  $self->pre_render();
+
+  # trigger rendering values for second row/longdescription as hidden,
+  # because they are loaded only on demand. So we need to keep the values
+  # from the source.
+  $_->{render_second_row}      = 1 for @{ $self->order->items_sorted };
+  $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
+
+  $self->render(
+    'delivery_order/form',
+    title => $self->get_title_for('edit'),
+    %{$self->{template_args}}
+  );
+
+}
+
+sub action_stock_in_out_dialog {
+  my ($self) = @_;
+
+  my $part    = SL::DB::Part->load_cached($::form->{parts_id}) or die "need parts_id";
+  my $unit    = SL::DB::Unit->load_cached($::form->{unit}) or die "need unit";
+  my $stock   = $::form->{stock};
+  my $row     = $::form->{row};
+  my $item_id = $::form->{item_id};
+  my $qty     = _parse_number($::form->{qty_as_number});
+
+  my $inout = $self->type_data->transfer;
+
+  my @contents   = DO->get_item_availability(parts_id => $part->id);
+  my $stock_info = DO->unpack_stock_information(packed => $stock);
+
+  $self->merge_stock_data($stock_info, \@contents, $part, $unit);
+
+  $self->render("delivery_order/stock_dialog", { layout => 0 },
+    WHCONTENTS => $self->order->delivered ? $stock_info : \@contents,
+    part       => $part,
+    do_qty     => $qty,
+    do_unit    => $unit->unit,
+    delivered  => $self->order->delivered,
+    row        => $row,
+    item_id    => $item_id,
+  );
+}
+
+sub action_update_stock_information {
+  my ($self) = @_;
+
+  my $stock_info = $::form->{stock_info};
+  my $unit = $::form->{unit};
+  my $yaml = SL::YAML::Dump($stock_info);
+  my $stock_qty = $self->calculate_stock_in_out_from_stock_info($unit, $stock_info);
+
+  my $response = {
+    stock_info => $yaml,
+    stock_qty => $stock_qty,
+  };
+  $self->render(\ SL::JSON::to_json($response), { layout => 0, type => 'json', process => 0 });
+}
+
+sub merge_stock_data {
+  my ($self, $stock_info, $contents, $part, $unit) = @_;
+  # TODO rewrite to mapping
+
+  if (!$self->order->delivered) {
+    for my $row (@$contents) {
+      # row here is in parts units. stock is in item units
+      $row->{available_qty} = _format_number($part->unit_obj->convert_to($row->{qty}, $unit));
+
+      for my $sinfo (@{ $stock_info }) {
+        next if $row->{bin_id}       != $sinfo->{bin_id} ||
+                $row->{warehouse_id} != $sinfo->{warehouse_id} ||
+                $row->{chargenumber} ne $sinfo->{chargenumber} ||
+                $row->{bestbefore}   ne $sinfo->{bestbefore};
+
+        $row->{"stock_$_"} = $sinfo->{$_}
+          for qw(qty unit error delivery_order_items_stock_id);
+      }
+    }
+
+  } else {
+    for my $sinfo (@{ $stock_info }) {
+      my $bin = SL::DB::Bin->load_cached($sinfo->{bin_id});
+      $sinfo->{warehousedescription} = $bin->warehouse->description;
+      $sinfo->{bindescription}       = $bin->description;
+      map { $sinfo->{"stock_$_"}      = $sinfo->{$_} } qw(qty unit);
+    }
+  }
+}
+
+# load the second row for one or more items
+#
+# This action gets the html code for all items second rows by rendering a template for
+# the second row and sets the html code via client js.
+sub action_load_second_rows {
+  my ($self) = @_;
+
+  foreach my $item_id (@{ $::form->{item_ids} }) {
+    my $idx  = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
+    my $item = $self->order->items_sorted->[$idx];
+
+    $self->js_load_second_row($item, $item_id, 0);
+  }
+
+  $self->js->run('kivi.DeliveryOrder.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
+
+  $self->js->render();
+}
+
+# update description, notes and sellprice from master data
+sub action_update_row_from_master_data {
+  my ($self) = @_;
+
+  foreach my $item_id (@{ $::form->{item_ids} }) {
+    my $idx   = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
+    my $item  = $self->order->items_sorted->[$idx];
+    my $texts = get_part_texts($item->part, $self->order->language_id);
+
+    $item->description($texts->{description});
+    $item->longdescription($texts->{longdescription});
+
+    my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
+
+    my $price_src;
+    if ($item->part->is_assortment) {
+    # add assortment items with price 0, as the components carry the price
+      $price_src = $price_source->price_from_source("");
+      $price_src->price(0);
+    } else {
+      $price_src = $price_source->best_price
+                 ? $price_source->best_price
+                 : $price_source->price_from_source("");
+      $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
+      $price_src->price(0) if !$price_source->best_price;
+    }
+
+
+    $item->sellprice($price_src->price);
+    $item->active_price_source($price_src);
+
+    $self->js
+      ->run('kivi.DeliveryOrder.update_sellprice', $item_id, $item->sellprice_as_number)
+      ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
+      ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
+      ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
+
+    if ($self->search_cvpartnumber) {
+      $self->get_item_cvpartnumber($item);
+      $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
+    }
+  }
+
+  $self->js_redisplay_line_values;
+
+  $self->js->render();
+}
+
+sub action_transfer_stock {
+  my ($self) = @_;
+
+  if ($self->order->delivered) {
+    return $self->js->flash("error", t8('The parts for this order have already been transferred'))->render;
+  }
+
+  my $inout = $self->type_data->properties('transfer');
+
+  my $errors = $self->save;
+
+  if (@$errors) {
+    $self->js->flash('error', $_) for @$errors;
+    return $self->js->render;
+  }
+
+  my $order = $self->order;
+
+  # TODO move to type data
+  my $trans_type = $inout eq 'in'
+    ? SL::DB::Manager::TransferType->find_by(direction => "id", description => "stock")
+    : SL::DB::Manager::TransferType->find_by(direction => "out", description => "shipped");
+
+  my @transfer_requests;
+
+  for my $item (@{ $order->items_sorted }) {
+    for my $stock (@{ $item->delivery_order_stock_entries }) {
+      my $transfer = SL::DB::Inventory->new_from($stock);
+      $transfer->trans_type($trans_type);
+      $transfer->qty($transfer->qty * -1) if $inout eq 'out';
+
+      push @transfer_requests, $transfer if defined $transfer->qty && $transfer->qty != 0;
+    };
+  }
+
+  if (!@transfer_requests) {
+    return $self->js->flash("error", t8("No stock to transfer"))->render;
+  }
+
+  SL::DB->client->with_transaction(sub {
+    $_->save for @transfer_requests;
+    $self->order->update_attributes(delivered => 1);
+  });
+
+  $self->js
+    ->flash("info", t8("Stock transfered"))
+    ->run('kivi.ActionBar.setDisabled', '#transfer_out_action', t8('The parts for this order have already been transferred'))
+    ->run('kivi.ActionBar.setDisabled', '#transfer_in_action', t8('The parts for this order have already been transferred'))
+    ->run('kivi.ActionBar.setDisabled', '#delete_action', t8('The parts for this order have already been transferred'))
+    ->replaceWith('#data-status-line', delivery_order_status_line($self->order))
+    ->render;
+
+}
+
+sub js_load_second_row {
+  my ($self, $item, $item_id, $do_parse) = @_;
+
+  if ($do_parse) {
+    # Parse values from form (they are formated while rendering (template)).
+    # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
+    # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
+    foreach my $var (@{ $item->cvars_by_config }) {
+      $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
+    }
+    $item->parse_custom_variable_values;
+  }
+
+  my $row_as_html = $self->p->render('delivery_order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
+
+  $self->js
+    ->html('#second_row_' . $item_id, $row_as_html)
+    ->data('#second_row_' . $item_id, 'loaded', 1);
+}
+
+sub js_redisplay_line_values {
+  my ($self) = @_;
+
+  my $is_sales = $self->order->is_sales;
+
+  # sales orders with margins
+  my @data;
+  if ($is_sales) {
+    @data = map {
+      [
+       $::form->format_amount(\%::myconfig, $_->{linetotal},     2, 0),
+       $::form->format_amount(\%::myconfig, $_->{marge_total},   2, 0),
+       $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
+      ]} @{ $self->order->items_sorted };
+  } else {
+    @data = map {
+      [
+       $::form->format_amount(\%::myconfig, $_->{linetotal},     2, 0),
+      ]} @{ $self->order->items_sorted };
+  }
+
+  $self->js
+    ->run('kivi.DeliveryOrder.redisplay_line_values', $is_sales, \@data);
+}
+
+sub js_redisplay_cvpartnumbers {
+  my ($self) = @_;
+
+  $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
+
+  my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
+
+  $self->js
+    ->run('kivi.DeliveryOrder.redisplay_cvpartnumbers', \@data);
+}
+
+sub js_reset_order_and_item_ids_after_save {
+  my ($self) = @_;
+
+  $self->js
+    ->val('#id', $self->order->id)
+    ->val('#converted_from_oe_id', '')
+    ->val('#order_' . $self->nr_key(), $self->order->number);
+
+  my $idx = 0;
+  foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
+    next if !$self->order->items_sorted->[$idx]->id;
+    next if $form_item_id !~ m{^new};
+    $self->js
+      ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
+      ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
+      ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
+  } continue {
+    $idx++;
+  }
+  $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
+}
+
+#
+# helpers
+#
+
+sub init_type {
+  my ($self) = @_;
+
+  if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
+    die "Not a valid type for delivery order";
+  }
+
+  $self->type($::form->{type});
+}
+
+sub init_cv {
+  my ($self) = @_;
+
+  return $self->type_data->customervendor;
+}
+
+sub init_search_cvpartnumber {
+  my ($self) = @_;
+
+  my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
+  my $search_cvpartnumber;
+  $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
+  $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel()        if $self->cv eq 'vendor';
+
+  return $search_cvpartnumber;
+}
+
+sub init_show_update_button {
+  my ($self) = @_;
+
+  !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
+}
+
+sub init_p {
+  SL::Presenter->get;
+}
+
+sub init_order {
+  $_[0]->make_order;
+}
+
+sub init_all_price_factors {
+  SL::DB::Manager::PriceFactor->get_all;
+}
+
+sub init_part_picker_classification_ids {
+  my ($self)    = @_;
+
+  return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => $self->type_data->part_classification_query) } ];
+}
+
+sub check_auth {
+  my ($self) = @_;
+
+  $::auth->assert($self->type_data->access || 'DOES_NOT_EXIST');
+}
+
+# build the selection box for contacts
+#
+# Needed, if customer/vendor changed.
+sub build_contact_select {
+  my ($self) = @_;
+
+  select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
+    value_key  => 'cp_id',
+    title_key  => 'full_name_dep',
+    default    => $self->order->cp_id,
+    with_empty => 1,
+    style      => 'width: 300px',
+  );
+}
+
+# build the selection box for shiptos
+#
+# Needed, if customer/vendor changed.
+sub build_shipto_select {
+  my ($self) = @_;
+
+  select_tag('order.shipto_id',
+             [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
+             value_key  => 'shipto_id',
+             title_key  => 'displayable_id',
+             default    => $self->order->shipto_id,
+             with_empty => 0,
+             style      => 'width: 300px',
+  );
+}
+
+# build the inputs for the cusom shipto dialog
+#
+# Needed, if customer/vendor changed.
+sub build_shipto_inputs {
+  my ($self) = @_;
+
+  my $content = $self->p->render('common/_ship_to_dialog',
+                                 vc_obj      => $self->order->customervendor,
+                                 cs_obj      => $self->order->custom_shipto,
+                                 cvars       => $self->order->custom_shipto->cvars_by_config,
+                                 id_selector => '#order_shipto_id');
+
+  div_tag($content, id => 'shipto_inputs');
+}
+
+# render the info line for business
+#
+# Needed, if customer/vendor changed.
+sub build_business_info_row
+{
+  $_[0]->p->render('delivery_order/tabs/_business_info_row', SELF => $_[0]);
+}
+
+
+sub load_order {
+  my ($self) = @_;
+
+  return if !$::form->{id};
+
+  $self->order(SL::DB::DeliveryOrder->new(id => $::form->{id})->load);
+
+  # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
+  # You need a custom shipto object to call cvars_by_config to get the cvars.
+  $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
+
+  $self->prepare_stock_info($_) for $self->order->items;
+
+  return $self->order;
+}
+
+# load or create a new order object
+#
+# And assign changes from the form to this object.
+# If the order is loaded from db, check if items are deleted in the form,
+# remove them form the object and collect them for removing from db on saving.
+# Then create/update items from form (via make_item) and add them.
+sub make_order {
+  my ($self) = @_;
+
+  # add_items adds items to an order with no items for saving, but they cannot
+  # be retrieved via items until the order is saved. Adding empty items to new
+  # order here solves this problem.
+  my $order;
+  $order   = SL::DB::DeliveryOrder->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
+  $order ||= SL::DB::DeliveryOrder->new(orderitems  => [], currency_id => $::instance_conf->get_currency_id(), order_type => $self->type_data->validate($::form->{type}));
+
+  my $cv_id_method = $self->cv . '_id';
+  if (!$::form->{id} && $::form->{$cv_id_method}) {
+    $order->$cv_id_method($::form->{$cv_id_method});
+    setup_order_from_cv($order);
+  }
+
+  my $form_orderitems                  = delete $::form->{order}->{orderitems};
+
+  $order->assign_attributes(%{$::form->{order}});
+
+  $self->setup_custom_shipto_from_form($order, $::form);
+
+  # remove deleted items
+  $self->item_ids_to_delete([]);
+  foreach my $idx (reverse 0..$#{$order->orderitems}) {
+    my $item = $order->orderitems->[$idx];
+    if (none { $item->id == $_->{id} } @{$form_orderitems}) {
+      splice @{$order->orderitems}, $idx, 1;
+      push @{$self->item_ids_to_delete}, $item->id;
+    }
+  }
+
+  my @items;
+  my $pos = 1;
+  foreach my $form_attr (@{$form_orderitems}) {
+    my $item = make_item($order, $form_attr);
+    $item->position($pos);
+    push @items, $item;
+    $pos++;
+  }
+
+  $self->prepare_stock_info($_) for $order->items, @items;
+
+  $order->add_items(grep {!$_->id} @items);
+
+  return $order;
+}
+
+# create or update items from form
+#
+# Make item objects from form values. For items already existing read from db.
+# Create a new item else. And assign attributes.
+sub make_item {
+  my ($record, $attr) = @_;
+
+  my $item;
+  $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
+
+  my $is_new = !$item;
+
+  # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
+  # they cannot be retrieved via custom_variables until the order/orderitem is
+  # saved. Adding empty custom_variables to new orderitem here solves this problem.
+  $item ||= SL::DB::DeliveryOrderItem->new(custom_variables => []);
+
+  # handle stock info
+  if (my $stock_info = delete $attr->{stock_info}) {
+    my %existing = map { $_->id => $_ } $item->delivery_order_stock_entries;
+    my @save;
+
+    for my $line (@{ DO->unpack_stock_information(packed => $stock_info) }) {
+      # lookup existing or make new
+      my $obj = delete $existing{$line->{delivery_order_items_stock_id}}
+             // SL::DB::DeliveryOrderItemsStock->new;
+
+      # assign attributes
+      $obj->$_($line->{$_}) for qw(bin_id warehouse_id chargenumber qty unit);
+      $obj->bestbefore_as_date($line->{bestfbefore})
+        if $line->{bestbefore} && $::instance_conf->get_show_bestbefore;
+      push @save, $obj if $obj->qty;
+    }
+
+    $item->delivery_order_stock_entries(@save);
+  }
+
+  $item->assign_attributes(%$attr);
+
+  if ($is_new) {
+    my $texts = get_part_texts($item->part, $record->language_id);
+    $item->longdescription($texts->{longdescription})              if !defined $attr->{longdescription};
+    $item->project_id($record->globalproject_id)                   if !defined $attr->{project_id};
+    $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
+  }
+
+  return $item;
+}
+
+# create a new item
+#
+# This is used to add one item
+sub new_item {
+  my ($record, $attr) = @_;
+
+  my $item = SL::DB::DeliveryOrderItem->new;
+
+  # Remove attributes where the user left or set the inputs empty.
+  # So these attributes will be undefined and we can distinguish them
+  # from zero later on.
+  for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
+    delete $attr->{$_} if $attr->{$_} eq '';
+  }
+
+  $item->assign_attributes(%$attr);
+
+  my $part         = SL::DB::Part->new(id => $attr->{parts_id})->load;
+  my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
+
+  $item->unit($part->unit) if !$item->unit;
+
+  my $price_src;
+  if ( $part->is_assortment ) {
+    # add assortment items with price 0, as the components carry the price
+    $price_src = $price_source->price_from_source("");
+    $price_src->price(0);
+  } elsif (defined $item->sellprice) {
+    $price_src = $price_source->price_from_source("");
+    $price_src->price($item->sellprice);
+  } else {
+    $price_src = $price_source->best_price
+               ? $price_source->best_price
+               : $price_source->price_from_source("");
+    $price_src->price(0) if !$price_source->best_price;
+  }
+
+  my $discount_src;
+  if (defined $item->discount) {
+    $discount_src = $price_source->discount_from_source("");
+    $discount_src->discount($item->discount);
+  } else {
+    $discount_src = $price_source->best_discount
+                  ? $price_source->best_discount
+                  : $price_source->discount_from_source("");
+    $discount_src->discount(0) if !$price_source->best_discount;
+  }
+
+  my %new_attr;
+  $new_attr{part}                   = $part;
+  $new_attr{description}            = $part->description     if ! $item->description;
+  $new_attr{qty}                    = 1.0                    if ! $item->qty;
+  $new_attr{price_factor_id}        = $part->price_factor_id if ! $item->price_factor_id;
+  $new_attr{sellprice}              = $price_src->price;
+  $new_attr{discount}               = $discount_src->discount;
+  $new_attr{active_price_source}    = $price_src;
+  $new_attr{active_discount_source} = $discount_src;
+  $new_attr{longdescription}        = $part->notes           if ! defined $attr->{longdescription};
+  $new_attr{project_id}             = $record->globalproject_id;
+  $new_attr{lastcost}               = $record->is_sales ? $part->lastcost : 0;
+
+  # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
+  # they cannot be retrieved via custom_variables until the order/orderitem is
+  # saved. Adding empty custom_variables to new orderitem here solves this problem.
+  $new_attr{custom_variables} = [];
+
+  my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
+
+  $item->assign_attributes(%new_attr, %{ $texts });
+
+  return $item;
+}
+
+sub prepare_stock_info {
+  my ($self, $item) = @_;
+
+  $item->{stock_info} = SL::YAML::Dump([
+    map +{
+      delivery_order_items_stock_id => $_->id,
+      qty                           => $_->qty,
+      warehouse_id                  => $_->warehouse_id,
+      bin_id                        => $_->bin_id,
+      chargenumber                  => $_->chargenumber,
+      unit                          => $_->unit,
+    }, $item->delivery_order_stock_entries
+  ]);
+}
+
+sub setup_order_from_cv {
+  my ($order) = @_;
+
+  $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id));
+
+  $order->intnotes($order->customervendor->notes);
+
+  if ($order->is_sales) {
+    $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
+    $order->taxincluded(defined($order->customer->taxincluded_checked)
+                        ? $order->customer->taxincluded_checked
+                        : $::myconfig{taxincluded_checked});
+  }
+
+}
+
+# setup custom shipto from form
+#
+# The dialog returns form variables starting with 'shipto' and cvars starting
+# with 'shiptocvar_'.
+# Mark it to be deleted if a shipto from master data is selected
+# (i.e. order has a shipto).
+# Else, update or create a new custom shipto. If the fields are empty, it
+# will not be saved on save.
+sub setup_custom_shipto_from_form {
+  my ($self, $order, $form) = @_;
+
+  if ($order->shipto) {
+    $self->is_custom_shipto_to_delete(1);
+  } else {
+    my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
+
+    my $shipto_cvars  = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
+    my $shipto_attrs  = {map {                                  $_   => delete $form->{$_}} grep { m{^shipto}      } keys %$form};
+
+    $custom_shipto->assign_attributes(%$shipto_attrs);
+    $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
+  }
+}
+
+# get data for saving, printing, ..., that is not changed in the form
+#
+# Only cvars for now.
+sub get_unalterable_data {
+  my ($self) = @_;
+
+  foreach my $item (@{ $self->order->items }) {
+    # autovivify all cvars that are not in the form (cvars_by_config can do it).
+    # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
+    foreach my $var (@{ $item->cvars_by_config }) {
+      $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
+    }
+    $item->parse_custom_variable_values;
+  }
+}
+
+# delete the order
+#
+# And remove related files in the spool directory
+sub delete {
+  my ($self) = @_;
+
+  my $errors = [];
+  my $db     = $self->order->db;
+
+  $db->with_transaction(
+    sub {
+      my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
+      $self->order->delete;
+      my $spool = $::lx_office_conf{paths}->{spool};
+      unlink map { "$spool/$_" } @spoolfiles if $spool;
+
+      $self->save_history('DELETED');
+
+      1;
+  }) || push(@{$errors}, $db->error);
+
+  return $errors;
+}
+
+# save the order
+#
+# And delete items that are deleted in the form.
+sub save {
+  my ($self) = @_;
+
+  my $errors = [];
+  my $db     = $self->order->db;
+
+  $db->with_transaction(sub {
+    # delete custom shipto if it is to be deleted or if it is empty
+    if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
+      $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
+      $self->order->custom_shipto(undef);
+    }
+
+    SL::DB::DeliveryOrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
+    $self->order->save(cascade => 1);
+
+    # link records
+    if ($::form->{converted_from_oe_id}) {
+      my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
+      foreach my $converted_from_oe_id (@converted_from_oe_ids) {
+        my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
+        $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/ && $self->order->is_type(PURCHASE_DELIVERY_ORDER_TYPE);
+        $src->link_to_record($self->order);
+      }
+      if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
+        my $idx = 0;
+        foreach (@{ $self->order->items_sorted }) {
+          my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
+          next if !$from_id;
+          SL::DB::RecordLink->new(from_table => 'orderitems',
+                                  from_id    => $from_id,
+                                  to_table   => 'orderitems',
+                                  to_id      => $_->id
+          )->save;
+          $idx++;
+        }
+      }
+    }
+
+    $self->save_history('SAVED');
+
+    1;
+  }) || push(@{$errors}, $db->error);
+
+  return $errors;
+}
+
+sub workflow_sales_or_request_for_quotation {
+  my ($self) = @_;
+
+  # always save
+  my $errors = $self->save();
+
+  if (scalar @{ $errors }) {
+    $self->js->flash('error', $_) for @{ $errors };
+    return $self->js->render();
+  }
+
+  my $destination_type = $self->type_data->workflow("to_quotation_type");
+
+  $self->order(SL::DB::DeliveryOrder->new_from($self->order, destination_type => $destination_type));
+  $self->{converted_from_oe_id} = delete $::form->{id};
+
+  # set item ids to new fake id, to identify them as new items
+  foreach my $item (@{$self->order->items_sorted}) {
+    $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
+  }
+
+  # change form type
+  $::form->{type} = $destination_type;
+  $self->type($self->init_type);
+  $self->cv  ($self->init_cv);
+  $self->check_auth;
+
+  $self->get_unalterable_data();
+  $self->pre_render();
+
+  # trigger rendering values for second row as hidden, because they
+  # are loaded only on demand. So we need to keep the values from the
+  # source.
+  $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
+
+  $self->render(
+    'delivery_order/form',
+    title => $self->get_title_for('edit'),
+    %{$self->{template_args}}
+  );
+}
+
+sub workflow_sales_or_purchase_order {
+  my ($self) = @_;
+
+  # always save
+  my $errors = $self->save();
+
+  if (scalar @{ $errors }) {
+    $self->js->flash('error', $_) foreach @{ $errors };
+    return $self->js->render();
+  }
+
+  my $destination_type = $self->type_data->workflow("to_order_type");
+
+  # check for direct delivery
+  # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
+  my $custom_shipto;
+  if ($self->type_data->workflow("to_order_copy_shipto") && $::form->{use_shipto} && $self->order->shipto) {
+    $custom_shipto = $self->order->shipto->clone('SL::DB::DeliveryOrder');
+  }
+
+  $self->order(SL::DB::DeliveryOrder->new_from($self->order, destination_type => $destination_type));
+  $self->{converted_from_oe_id} = delete $::form->{id};
+
+  # set item ids to new fake id, to identify them as new items
+  foreach my $item (@{$self->order->items_sorted}) {
+    $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
+  }
+
+  if ($self->type_data->workflow("to_order_copy_shipto")) {
+    if ($::form->{use_shipto}) {
+      $self->order->custom_shipto($custom_shipto) if $custom_shipto;
+    } else {
+      # remove any custom shipto if not wanted
+      $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
+    }
+  }
+
+  # change form type
+  $::form->{type} = $destination_type;
+  $self->type($self->init_type);
+  $self->cv  ($self->init_cv);
+  $self->check_auth;
+
+  $self->get_unalterable_data();
+  $self->pre_render();
+
+  # trigger rendering values for second row as hidden, because they
+  # are loaded only on demand. So we need to keep the values from the
+  # source.
+  $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
+
+  $self->render(
+    'delivery_order/form',
+    title => $self->get_title_for('edit'),
+    %{$self->{template_args}}
+  );
+}
+
+sub pre_render {
+  my ($self) = @_;
+
+  $self->{all_taxzones}               = SL::DB::Manager::TaxZone->get_all_sorted();
+  $self->{all_currencies}             = SL::DB::Manager::Currency->get_all_sorted();
+  $self->{all_departments}            = SL::DB::Manager::Department->get_all_sorted();
+  $self->{all_languages}              = SL::DB::Manager::Language->get_all_sorted();
+  $self->{all_employees}              = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
+                                                                                              deleted => 0 ] ],
+                                                                           sort_by => 'name');
+  $self->{all_salesmen}               = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
+                                                                                              deleted => 0 ] ],
+                                                                           sort_by => 'name');
+  $self->{all_payment_terms}          = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
+                                                                                                        obsolete => 0 ] ]);
+  $self->{all_delivery_terms}         = SL::DB::Manager::DeliveryTerm->get_all_sorted();
+  $self->{current_employee_id}        = SL::DB::Manager::Employee->current->id;
+  $self->{order_probabilities}        = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
+  $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
+
+  my $print_form = Form->new('');
+  $print_form->{type}        = $self->type;
+  $print_form->{printers}    = SL::DB::Manager::Printer->get_all_sorted;
+  $self->{print_options}     = SL::Helper::PrintOptions->get_print_options(
+    form => $print_form,
+    options => {dialog_name_prefix => 'print_options.',
+                show_headers       => 1,
+                no_queue           => 1,
+                no_postscript      => 1,
+                no_opendocument    => 0,
+                no_html            => 1},
+  );
+
+  foreach my $item (@{$self->order->orderitems}) {
+    my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
+    $item->active_price_source(   $price_source->price_from_source(   $item->active_price_source   ));
+    $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
+  }
+
+  if ($self->order->${\ $self->type_data->nr_key } && $::instance_conf->get_webdav) {
+    my $webdav = SL::Webdav->new(
+      type     => $self->type,
+      number   => $self->order->number,
+    );
+    my @all_objects = $webdav->get_all_objects;
+    @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
+                                                    type => t8('File'),
+                                                    link => File::Spec->catfile($_->full_filedescriptor),
+                                                } } @all_objects;
+  }
+
+  $self->{template_args}{in_out} = $self->type_data->transfer;
+
+  $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
+
+  $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.DeliveryOrder kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
+                                                         calculate_qty kivi.Validator follow_up show_history);
+  $self->setup_edit_action_bar;
+}
+
+sub setup_edit_action_bar {
+  my ($self, %params) = @_;
+
+  my $deletion_allowed = $self->type_data->show_menu("delete");
+
+  for my $bar ($::request->layout->get('actionbar')) {
+    $bar->add(
+      combobox => [
+        action => [
+          t8('Save'),
+          call      => [ 'kivi.DeliveryOrder.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
+                                                    $::instance_conf->get_order_warn_no_deliverydate,
+                                                                                                      ],
+        ],
+        action => [
+          t8('Save as new'),
+          call      => [ 'kivi.DeliveryOrder.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
+          disabled  =>   $self->type eq 'supplier_delivery_order' ? t8('Need a workflow for Supplier Delivery Order')
+                       : !$self->order->id                        ? t8('This object has not been saved yet.')
+                       : undef,
+        ],
+      ], # end of combobox "Save"
+
+      combobox => [
+        action => [
+          t8('Workflow'),
+        ],
+        action => [
+          t8('Save and Quotation'),
+          submit   => [ '#order_form', { action => "DeliveryOrder/sales_quotation" } ],
+          only_if  => $self->type_data->show_menu("save_and_quotation"),
+        ],
+        action => [
+          t8('Save and RFQ'),
+          submit   => [ '#order_form', { action => "DeliveryOrder/request_for_quotation" } ],
+          only_if  => $self->type_data->show_menu("save_and_rfq"),
+        ],
+        action => [
+          t8('Save and Sales Order'),
+          submit   => [ '#order_form', { action => "DeliveryOrder/sales_order" } ],
+          only_if  => $self->type_data->show_menu("save_and_sales_order"),
+        ],
+        action => [
+          t8('Save and Purchase Order'),
+          call      => [ 'kivi.DeliveryOrder.purchase_order_check_for_direct_delivery' ],
+          only_if  => $self->type_data->show_menu("save_and_purchase_order"),
+        ],
+        action => [
+          t8('Save and Delivery Order'),
+          call      => [ 'kivi.DeliveryOrder.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
+                                                                       $::instance_conf->get_order_warn_no_deliverydate,
+                                                                                                                        ],
+          only_if  => $self->type_data->show_menu("save_and_delivery_order"),
+        ],
+        action => [
+          t8('Save and Invoice'),
+          call      => [ 'kivi.DeliveryOrder.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
+          only_if  => $self->type_data->show_menu("save_and_invoice"),
+        ],
+        action => [
+          t8('Save and AP Transaction'),
+          call      => [ 'kivi.DeliveryOrder.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
+          only_if  => $self->type_data->show_menu("save_and_ap_transaction"),
+        ],
+
+      ], # end of combobox "Workflow"
+
+      combobox => [
+        action => [
+          t8('Export'),
+        ],
+        action => [
+          t8('Save and preview PDF'),
+           call => [ 'kivi.DeliveryOrder.save', 'preview_pdf', $::instance_conf->get_order_warn_duplicate_parts,
+                                                       $::instance_conf->get_order_warn_no_deliverydate,
+                                                                                                         ],
+        ],
+        action => [
+          t8('Save and print'),
+          call => [ 'kivi.DeliveryOrder.show_print_options', $::instance_conf->get_order_warn_duplicate_parts,
+                                                     $::instance_conf->get_order_warn_no_deliverydate,
+                                                                                                      ],
+        ],
+        action => [
+          t8('Save and E-mail'),
+          id   => 'save_and_email_action',
+          call => [ 'kivi.DeliveryOrder.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts,
+                                                                     $::instance_conf->get_order_warn_no_deliverydate,
+                  ],
+          disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
+        ],
+        action => [
+          t8('Download attachments of all parts'),
+          call     => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
+          disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
+          only_if  => $::instance_conf->get_doc_storage,
+        ],
+      ], # end of combobox "Export"
+
+      action => [
+        t8('Delete'),
+        id       => 'delete_action',
+        call     => [ 'kivi.DeliveryOrder.delete_order' ],
+        confirm  => $::locale->text('Do you really want to delete this object?'),
+        disabled => !$self->order->id       ? t8('This object has not been saved yet.') :
+                    $self->order->delivered ? t8('The parts for this order have already been transferred') : undef,
+        only_if  => $self->type_data->show_menu("delete"),
+      ],
+
+      combobox => [
+        action => [
+          t8('Transfer out'),
+          id   => 'transfer_out_action',
+          call   => [ 'kivi.DeliveryOrder.save', 'transfer_stock' ],
+          disabled => !$self->order->id ? t8('This object has not been saved yet.') :
+                      $self->order->delivered ? t8('The parts for this order have already been transferred') : undef,
+          only_if => $self->type_data->properties('transfer') eq 'out',
+          confirm  => t8('Do you really want to transfer the stock and set this order to delivered?'),
+        ],
+        action => [
+          t8('Transfer in'),
+          id   => 'transfer_in_action',
+          call   => [ 'kivi.DeliveryOrder.save', 'transfer_stock' ],
+          disabled => !$self->order->id ? t8('This object has not been saved yet.') :
+                      $self->order->delivered ? t8('The parts for this order have already been transferred') : undef,
+          only_if => $self->type_data->properties('transfer') eq 'in',
+          confirm  => t8('Do you really want to transfer the stock and set this order to delivered?'),
+        ],
+      ],
+
+      combobox => [
+        action => [
+          t8('more')
+        ],
+        action => [
+          t8('Follow-Up'),
+          call     => [ 'kivi.DeliveryOrder.follow_up_window' ],
+          disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
+          only_if  => $::auth->assert('productivity', 1),
+        ],
+        action => [
+          t8('History'),
+          call     => [ 'set_history_window', $self->order->id, 'id' ],
+          disabled => !$self->order->id ? t8('This record has not been saved yet.') : undef,
+        ],
+      ], # end of combobox "more"
+    );
+  }
+}
+
+sub generate_pdf {
+  my ($order, $pdf_ref, $params) = @_;
+
+  my @errors = ();
+
+  my $print_form = Form->new('');
+  $print_form->{type}        = $order->type;
+  $print_form->{formname}    = $params->{formname} || $order->type;
+  $print_form->{format}      = $params->{format}   || 'pdf';
+  $print_form->{media}       = $params->{media}    || 'file';
+  $print_form->{groupitems}  = $params->{groupitems};
+  $print_form->{printer_id}  = $params->{printer_id};
+  $print_form->{media}       = 'file'                             if $print_form->{media} eq 'screen';
+
+  $order->language($params->{language});
+  $order->flatten_to_form($print_form, format_amounts => 1);
+
+  my $template_ext;
+  my $template_type;
+  if ($print_form->{format} =~ /(opendocument|oasis)/i) {
+    $template_ext  = 'odt';
+    $template_type = 'OpenDocument';
+  }
+
+  # search for the template
+  my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
+    name        => $print_form->{formname},
+    extension   => $template_ext,
+    email       => $print_form->{media} eq 'email',
+    language    => $params->{language},
+    printer_id  => $print_form->{printer_id},
+  );
+
+  if (!defined $template_file) {
+    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);
+  }
+
+  return @errors if scalar @errors;
+
+  $print_form->throw_on_error(sub {
+    eval {
+      $print_form->prepare_for_printing;
+
+      $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
+        format        => $print_form->{format},
+        template_type => $template_type,
+        template      => $template_file,
+        variables     => $print_form,
+        variable_content_types => {
+          longdescription => 'html',
+          partnotes       => 'html',
+          notes           => 'html',
+        },
+      );
+      1;
+    } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
+  });
+
+  return @errors;
+}
+
+sub get_files_for_email_dialog {
+  my ($self) = @_;
+
+  my %files = map { ($_ => []) } qw(versions files vc_files part_files);
+
+  return %files if !$::instance_conf->get_doc_storage;
+
+  if ($self->order->id) {
+    $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id,              object_type => $self->order->type, file_type => 'document') ];
+    $files{files}    = [ SL::File->get_all(         object_id => $self->order->id,              object_type => $self->order->type, file_type => 'attachment') ];
+    $files{vc_files} = [ SL::File->get_all(         object_id => $self->order->{$self->cv}->id, object_type => $self->cv,          file_type => 'attachment') ];
+    $files{project_files} = [ SL::File->get_all(    object_id => $self->order->globalproject_id, object_type => 'project',         file_type => 'attachment') ];
+  }
+
+  my @parts =
+    uniq_by { $_->{id} }
+    map {
+      +{ id         => $_->part->id,
+         partnumber => $_->part->partnumber }
+    } @{$self->order->items_sorted};
+
+  foreach my $part (@parts) {
+    my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
+    push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
+  }
+
+  foreach my $key (keys %files) {
+    $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
+  }
+
+  return %files;
+}
+
+sub get_title_for {
+  my ($self, $action) = @_;
+
+  return '' if none { lc($action)} qw(add edit);
+  return $self->type_data->text($action);
+}
+
+sub get_item_cvpartnumber {
+  my ($self, $item) = @_;
+
+  return if !$self->search_cvpartnumber;
+  return if !$self->order->customervendor;
+
+  if ($self->cv eq 'vendor') {
+    my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
+    $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
+  } elsif ($self->cv eq 'customer') {
+    my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
+    $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
+  }
+}
+
+sub get_part_texts {
+  my ($part_or_id, $language_or_id, %defaults) = @_;
+
+  my $part        = ref($part_or_id)     ? $part_or_id         : SL::DB::Part->load_cached($part_or_id);
+  my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
+  my $texts       = {
+    description     => $defaults{description}     // $part->description,
+    longdescription => $defaults{longdescription} // $part->notes,
+  };
+
+  return $texts unless $language_id;
+
+  my $translation = SL::DB::Manager::Translation->get_first(
+    where => [
+      parts_id    => $part->id,
+      language_id => $language_id,
+    ]);
+
+  $texts->{description}     = $translation->translation     if $translation && $translation->translation;
+  $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
+
+  return $texts;
+}
+
+sub nr_key {
+  return $_[0]->type_data->nr_key;
+}
+
+sub save_and_redirect_to {
+  my ($self, %params) = @_;
+
+  my $errors = $self->save();
+
+  if (scalar @{ $errors }) {
+    $self->js->flash('error', $_) foreach @{ $errors };
+    return $self->js->render();
+  }
+
+  flash_later('info', $self->type_data->text("saved"));
+
+  $self->redirect_to(%params, id => $self->order->id);
+}
+
+sub save_history {
+  my ($self, $addition) = @_;
+
+  my $number_type = $self->nr_key;
+  my $snumbers    = $number_type . '_' . $self->order->$number_type;
+
+  SL::DB::History->new(
+    trans_id    => $self->order->id,
+    employee_id => SL::DB::Manager::Employee->current->id,
+    what_done   => $self->order->type,
+    snumbers    => $snumbers,
+    addition    => $addition,
+  )->save;
+}
+
+sub store_pdf_to_webdav_and_filemanagement {
+  my($order, $content, $filename) = @_;
+
+  my @errors;
+
+  # copy file to webdav folder
+  if ($order->number && $::instance_conf->get_webdav_documents) {
+    my $webdav = SL::Webdav->new(
+      type     => $order->type,
+      number   => $order->number,
+    );
+    my $webdav_file = SL::Webdav::File->new(
+      webdav   => $webdav,
+      filename => $filename,
+    );
+    eval {
+      $webdav_file->store(data => \$content);
+      1;
+    } or do {
+      push @errors, t8('Storing PDF to webdav folder failed: #1', $@);
+    };
+  }
+  if ($order->id && $::instance_conf->get_doc_storage) {
+    eval {
+      SL::File->save(object_id     => $order->id,
+                     object_type   => $order->type,
+                     mime_type     => 'application/pdf',
+                     source        => 'created',
+                     file_type     => 'document',
+                     file_name     => $filename,
+                     file_contents => $content);
+      1;
+    } or do {
+      push @errors, t8('Storing PDF in storage backend failed: #1', $@);
+    };
+  }
+
+  return @errors;
+}
+
+sub calculate_stock_in_out_from_stock_info {
+  my ($self, $unit, $stock_info) = @_;
+
+  return "" if !$unit;
+
+  my %units_by_name = map { $_->name => $_ } @{ SL::DB::Manager::Unit->get_all };
+
+  my $sum      = sum0 map {
+    $units_by_name{$_->{unit}}->convert_to($_->{qty}, $units_by_name{$unit})
+  } @$stock_info;
+
+  my $content  = _format_number($sum, 2) . ' ' . $unit;
+
+  return $content;
+}
+
+sub calculate_stock_in_out {
+  my ($self, $item, $stock_info) = @_;
+
+  return "" if !$item->part || !$item->part->unit || !$item->unit;
+
+  my $sum      = sum0 map {
+    $_->unit_obj->convert_to($_->qty, $item->unit_obj)
+  } $item->delivery_order_stock_entries;
+
+  my $content  = _format_number($sum, 2);
+
+  return $content;
+}
+
+sub init_type_data {
+  SL::Controller::DeliveryOrder::TypeData->new($_[0]);
+}
+
+sub init_valid_types {
+  $_[0]->type_data->valid_types;
+}
+
+1;
+
+__END__
+
+=encoding utf-8
+
+=head1 NAME
+
+SL::Controller::Order - controller for orders
+
+=head1 SYNOPSIS
+
+This is a new form to enter orders, completely rewritten with the use
+of controller and java script techniques.
+
+The aim is to provide the user a better experience and a faster workflow. Also
+the code should be more readable, more reliable and better to maintain.
+
+=head2 Key Features
+
+=over 4
+
+=item *
+
+One input row, so that input happens every time at the same place.
+
+=item *
+
+Use of pickers where possible.
+
+=item *
+
+Possibility to enter more than one item at once.
+
+=item *
+
+Item list in a scrollable area, so that the workflow buttons stay at
+the bottom.
+
+=item *
+
+Reordering item rows with drag and drop is possible. Sorting item rows is
+possible (by partnumber, description, qty, sellprice and discount for now).
+
+=item *
+
+No C<update> is necessary. All entries and calculations are managed
+with ajax-calls and the page only reloads on C<save>.
+
+=item *
+
+User can see changes immediately, because of the use of java script
+and ajax.
+
+=back
+
+=head1 CODE
+
+=head2 Layout
+
+=over 4
+
+=item * C<SL/Controller/Order.pm>
+
+the controller
+
+=item * C<template/webpages/delivery_order/form.html>
+
+main form
+
+=item * C<template/webpages/delivery_order/tabs/basic_data.html>
+
+Main tab for basic_data.
+
+This is the only tab here for now. "linked records" and "webdav" tabs are
+reused from generic code.
+
+=over 4
+
+=item * C<template/webpages/delivery_order/tabs/_business_info_row.html>
+
+For displaying information on business type
+
+=item * C<template/webpages/delivery_order/tabs/_item_input.html>
+
+The input line for items
+
+=item * C<template/webpages/delivery_order/tabs/_row.html>
+
+One row for already entered items
+
+=item * C<template/webpages/delivery_order/tabs/_tax_row.html>
+
+Displaying tax information
+
+=back
+
+=item * C<js/kivi.DeliveryOrder.js>
+
+java script functions
+
+=back
+
+=head1 TODO
+
+=over 4
+
+=item * testing
+
+=item * price sources: little symbols showing better price / better discount
+
+=item * select units in input row?
+
+=item * check for direct delivery (workflow sales order -> purchase order)
+
+=item * access rights
+
+=item * display weights
+
+=item * mtime check
+
+=item * optional client/user behaviour
+
+(transactions has to be set - department has to be set -
+ force project if enabled in client config - transport cost reminder)
+
+=back
+
+=head1 KNOWN BUGS AND CAVEATS
+
+=over 4
+
+=item *
+
+Customer discount is not displayed as a valid discount in price source popup
+(this might be a bug in price sources)
+
+(I cannot reproduce this (Bernd))
+
+=item *
+
+No indication that <shift>-up/down expands/collapses second row.
+
+=item *
+
+Inline creation of parts is not currently supported
+
+=item *
+
+Table header is not sticky in the scrolling area.
+
+=item *
+
+Sorting does not include C<position>, neither does reordering.
+
+This behavior was implemented intentionally. But we can discuss, which behavior
+should be implemented.
+
+=back
+
+=head1 To discuss / Nice to have
+
+=over 4
+
+=item *
+
+How to expand/collapse second row. Now it can be done clicking the icon or
+<shift>-up/down.
+
+=item *
+
+Possibility to select PriceSources in input row?
+
+=item *
+
+This controller uses a (changed) copy of the template for the PriceSource
+dialog. Maybe there could be used one code source.
+
+=item *
+
+Rounding-differences between this controller (PriceTaxCalculator) and the old
+form. This is not only a problem here, but also in all parts using the PTC.
+There exists a ticket and a patch. This patch should be testet.
+
+=item *
+
+An indicator, if the actual inputs are saved (like in an
+editor or on text processing application).
+
+=item *
+
+A warning when leaving the page without saveing unchanged inputs.
+
+
+=back
+
+=head1 AUTHOR
+
+Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>
+
+=cut
diff --git a/SL/Controller/DeliveryOrder/TypeData.pm b/SL/Controller/DeliveryOrder/TypeData.pm
new file mode 100644 (file)
index 0000000..4aa2925
--- /dev/null
@@ -0,0 +1,100 @@
+package SL::Controller::DeliveryOrder::TypeData;
+
+use strict;
+use Exporter qw(import);
+use Scalar::Util qw(weaken);
+use SL::Locale::String qw(t8);
+use SL::DB::DeliveryOrder::TypeData qw(:types :subs);
+
+my @export_types = qw(SALES_DELIVERY_ORDER_TYPE PURCHASE_DELIVERY_ORDER_TYPE SUPPLIER_DELIVERY_ORDER_TYPE RMA_DELIVERY_ORDER_TYPE);
+
+our @EXPORT_OK = (@export_types);
+our %EXPORT_TAGS = (types => \@export_types);
+
+use Rose::Object::MakeMethods::Generic scalar => [ qw(c) ];
+
+sub new {
+  my ($class, $controller) = @_;
+  my $o = bless {}, $class;
+
+  if ($controller) {
+    $o->c($controller);
+    weaken($o->{c});
+  }
+
+  return $o;
+}
+
+sub validate {
+  my ($self, $string) = @_;
+  validate_type($string);
+}
+
+sub text {
+  my ($self, $string) = @_;
+  get3($self->c->type, "text", $string);
+}
+
+sub show_menu {
+  my ($self, $string) = @_;
+  get3($self->c->type, "show_menu", $string);
+}
+
+sub workflow {
+  my ($self, $string) = @_;
+  get3($self->c->type, "workflow", $string);
+}
+
+sub properties {
+  my ($self, $string) = @_;
+  get3($self->c->type, "properties", $string);
+}
+
+sub access {
+  get($_[0]->c->type, "right");
+}
+
+sub is_quotation {
+  get3($_[0]->c->type, "properties", "is_quotation");
+}
+
+sub customervendor {
+  get3($_[0]->c->type, "properties", "customervendor");
+}
+
+sub is_customer {
+  get3($_[0]->c->type, "properties", "is_customer");
+}
+
+sub nr_key {
+  get3($_[0]->c->type, "properties", "nr_key");
+}
+
+sub transfer {
+  get3($_[0]->c->type, "properties", "transfer");
+}
+
+sub part_classification_query {
+  my ($self, $string) = @_;
+  get($self->c->type, "part_classification_query");
+}
+
+sub set_reqdate_by_type {
+  my ($self) = @_;
+
+  if (!$self->c->order->reqdate) {
+    $self->c->order->reqdate(DateTime->today_local->next_workday(extra_days => 1));
+  }
+}
+
+sub get_reqdate_by_type {
+  my ($self, $reqdate, $saved_reqdate) = @_;
+
+  if ($reqdate == $saved_reqdate) {
+    return DateTime->today_local->next_workday(extra_days => 1);
+  } else {
+    return $reqdate;
+  }
+}
+
+1;
index b4328a1..d605f66 100644 (file)
@@ -677,6 +677,16 @@ sub action_save_and_delivery_order {
   );
 }
 
+sub action_save_and_supplier_delivery_order {
+  my ($self) = @_;
+
+  $self->save_and_redirect_to(
+    controller => 'controller.pl',
+    action     => 'DeliveryOrder/add_from_order',
+    type       => 'supplier_delivery_order',
+  );
+}
+
 # save the order and redirect to the frontend subroutine for a new
 # invoice
 sub action_save_and_invoice {
@@ -2065,6 +2075,16 @@ sub setup_edit_action_bar {
           ],
           only_if   => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
         ],
+        action => [
+          t8('Save and Supplier Delivery Order'),
+          call      => [ 'kivi.Order.save', 'save_and_supplier_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
+                                                                       $::instance_conf->get_order_warn_no_deliverydate,
+                                                                                                                        ],
+          checks    => [ 'kivi.Order.check_save_active_periodic_invoices',
+                         @req_trans_cost_art, @req_cusordnumber,
+          ],
+          only_if   => (any { $self->type eq $_ } (purchase_order_type()))
+        ],
         action => [
           t8('Save and Invoice'),
           call      => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
index 900123e..c5d30c7 100644 (file)
@@ -43,10 +43,12 @@ my @link_type_specifics = (
   { title => t8('Sales quotation'),         type => 'sales_quotation',         model => 'Order',           number => 'quonumber', },
   { title => t8('Sales Order'),             type => 'sales_order',             model => 'Order',           number => 'ordnumber', },
   { title => t8('Sales delivery order'),    type => 'sales_delivery_order',    model => 'DeliveryOrder',   number => 'donumber',  },
+  { title => t8('RMA delivery order'),      type => 'rma_delivery_order',      model => 'DeliveryOrder',   number => 'rdonumber', },
   { title => t8('Sales Invoice'),           type => 'invoice',                 model => 'Invoice',         number => 'invnumber', },
   { title => t8('Request for Quotation'),   type => 'request_quotation',       model => 'Order',           number => 'quonumber', },
   { title => t8('Purchase Order'),          type => 'purchase_order',          model => 'Order',           number => 'ordnumber', },
   { title => t8('Purchase delivery order'), type => 'purchase_delivery_order', model => 'DeliveryOrder',   number => 'donumber',  },
+  { title => t8('Supplier delivery order'), type => 'supplier_delivery_order', model => 'DeliveryOrder',   number => 'sdonumber', },
   { title => t8('Purchase Invoice'),        type => 'purchase_invoice',        model => 'PurchaseInvoice', number => 'invnumber', },
   { title => t8('Letter'),                  type => 'letter',                  model => 'Letter',          number => 'letternumber', description => 'subject', description_title => t8('Subject'), date => 'date', project => undef },
   { title => t8('Email'),                   type => 'email_journal',           model => 'EmailJournal',    number => 'id', description => 'subject', description_title => t8('Subject'), },
index f4e6301..45d6588 100644 (file)
@@ -17,6 +17,8 @@ use SL::DB::Helper::TransNumberGenerator;
 use SL::DB::Part;
 use SL::DB::Unit;
 
+use SL::DB::DeliveryOrder::TypeData qw(:types);
+
 use SL::Helper::Number qw(_format_total _round_total);
 
 use List::Util qw(first);
@@ -77,7 +79,11 @@ sub sales_order {
 }
 
 sub type {
-  return shift->customer_id ? 'sales_delivery_order' : 'purchase_delivery_order';
+  goto &order_type;
+}
+
+sub is_type {
+  return shift->type eq shift;
 }
 
 sub displayable_type {
@@ -105,6 +111,10 @@ sub date {
   goto &transdate;
 }
 
+sub number {
+  goto &donumber;
+}
+
 sub _clone_orderitem_cvar {
   my ($cvar) = @_;
 
@@ -130,8 +140,8 @@ sub new_from {
                                                 ordnumber payment_id reqdate salesman_id shippingpoint shipvia taxincluded taxzone_id transaction_description vendor_id billing_address_id
                                              )),
                closed    => 0,
-               is_sales  => !!$source->customer_id,
                delivered => 0,
+               order_type => $params{type},
                transdate => DateTime->today_local,
             );
 
@@ -146,12 +156,19 @@ sub new_from {
     $args{shipto_id} = $source->shipto_id;
   }
 
+  # infer type from legacy fields if not given
+  $args{order_type} //= $source->customer_id ? 'sales_delivery_order'
+                      : $source->vendor_id   ? 'purchase_delivery_order'
+                      : $source->is_sales    ? 'sales_delivery_order'
+                      : croak "need some way to set delivery order type from source";
+
   my $delivery_order = $class->new(%args);
   $delivery_order->assign_attributes(%{ $params{attributes} }) if $params{attributes};
   my $items          = delete($params{items}) || $source->items_sorted;
   my %item_parents;
 
-  my @items = map {
+  # do not copy items when converting to supplier delivery order
+  my @items = $delivery_order->is_type(SUPPLIER_DELIVERY_ORDER_TYPE) ? () : map {
     my $source_item      = $_;
     my $source_item_id   = $_->$item_parent_id_column;
     my @custom_variables = map { _clone_orderitem_cvar($_) } @{ $source_item->custom_variables };
@@ -287,6 +304,7 @@ sub new_from_time_recordings {
   } else {
     my %args = (
       is_sales    => 1,
+      order_type  => 'sales_delivery_order',
       delivered   => 0,
       customer_id => $sources->[0]->customer_id,
       taxzone_id  => $sources->[0]->customer->taxzone_id,
@@ -302,8 +320,17 @@ sub new_from_time_recordings {
   return $delivery_order;
 }
 
+# legacy for compatibility
+# use type_data cusomtervendor and transfer direction instead
+sub is_sales {
+  if ($_[0]->order_type) {
+   return SL::DB::DeliveryOrder::TypeData::get3($_[0]->order_type, "properties", "is_customer");
+  }
+  return $_[0]{is_sales};
+}
+
 sub customervendor {
-  $_[0]->is_sales ? $_[0]->customer : $_[0]->vendor;
+  SL::DB::DeliveryOrder::TypeData::get3($_[0]->order_type, "properties", "is_customer") ? $_[0]->customer : $_[0]->vendor;
 }
 
 sub convert_to_invoice {
diff --git a/SL/DB/DeliveryOrder/TypeData.pm b/SL/DB/DeliveryOrder/TypeData.pm
new file mode 100644 (file)
index 0000000..141085a
--- /dev/null
@@ -0,0 +1,188 @@
+package SL::DB::DeliveryOrder::TypeData;
+
+use strict;
+use Carp;
+use Exporter qw(import);
+use Scalar::Util qw(weaken);
+use SL::Locale::String qw(t8);
+
+use constant {
+  SALES_DELIVERY_ORDER_TYPE    => 'sales_delivery_order',
+  PURCHASE_DELIVERY_ORDER_TYPE => 'purchase_delivery_order',
+  SUPPLIER_DELIVERY_ORDER_TYPE => 'supplier_delivery_order',
+  RMA_DELIVERY_ORDER_TYPE      => 'rma_delivery_order',
+};
+
+my @export_types = qw(SALES_DELIVERY_ORDER_TYPE PURCHASE_DELIVERY_ORDER_TYPE SUPPLIER_DELIVERY_ORDER_TYPE RMA_DELIVERY_ORDER_TYPE);
+my @export_subs = qw(valid_types validate_type is_valid_type get get3);
+
+our @EXPORT_OK = (@export_types, @export_subs);
+our %EXPORT_TAGS = (types => \@export_types, subs => \@export_subs);
+
+my %type_data = (
+  SALES_DELIVERY_ORDER_TYPE() => {
+    text => {
+      delete => t8('Delivery Order has been deleted'),
+      saved  => t8('Delivery Order has been saved'),
+      add    => t8("Add Sales Delivery Order"),
+      edit   => t8("Edit Sales Delivery Order"),
+      attachment => t8("sales_delivery_order_list"),
+    },
+    show_menu => {
+      save_and_quotation      => 0,
+      save_and_rfq            => 0,
+      save_and_sales_order    => 0,
+      save_and_purchase_order => 0,
+      save_and_delivery_order => 0,
+      save_and_ap_transaction => 0,
+      save_and_invoice        => 0,
+      delete                  => sub { $::instance_conf->get_sales_delivery_order_show_delete },
+      new_controller          => 0,
+    },
+    properties => {
+      customervendor => "customer",
+      is_customer    => 1,
+      nr_key         => "donumber",
+      transfer       => 'out',
+      transnumber    => 'sdonumber',
+    },
+    part_classification_query => [ "used_for_sale" => 1 ],
+    right => "sales_delivery_order_edit",
+  },
+  PURCHASE_DELIVERY_ORDER_TYPE() => {
+    text => {
+      delete => t8('Delivery Order has been deleted'),
+      saved  => t8('Delivery Order has been saved'),
+      add    => t8("Add Purchase Delivery Order"),
+      edit   => t8("Edit Purchase Delivery Order"),
+      attachment => t8("purchase_delivery_order_list"),
+    },
+    show_menu => {
+      save_and_quotation      => 0,
+      save_and_rfq            => 0,
+      save_and_sales_order    => 0,
+      save_and_purchase_order => 0,
+      save_and_delivery_order => 0,
+      save_and_ap_transaction => 0,
+      save_and_invoice        => 0,
+      delete                  => sub { $::instance_conf->get_sales_delivery_order_show_delete },
+      new_controller          => 0,
+    },
+    properties => {
+      customervendor => "vendor",
+      is_customer    => 0,
+      nr_key         => "donumber",
+      transfer       => 'in',
+      transnumber    => 'pdonumber',
+    },
+    part_classification_query => [ "used_for_purchase" => 1 ],
+    right => "purchase_delivery_order_edit",
+  },
+  SUPPLIER_DELIVERY_ORDER_TYPE() => {
+    text => {
+      delete => t8('Supplier Delivery Order has been deleted'),
+      saved  => t8('Supplier Delivery Order has been saved'),
+      add    => t8("Add Supplier Delivery Order"),
+      edit   => t8("Edit Supplier Delivery Order"),
+      attachment => t8("supplier_delivery_order_list"),
+    },
+    show_menu => {
+      save_and_quotation      => 0,
+      save_and_rfq            => 0,
+      save_and_sales_order    => 0,
+      save_and_purchase_order => 0,
+      save_and_delivery_order => 0,
+      save_and_ap_transaction => 0,
+      save_and_invoice        => 0,
+      delete                  => sub { $::instance_conf->get_sales_delivery_order_show_delete },
+      new_controller          => 1,
+    },
+    properties => {
+      customervendor => "vendor",
+      is_customer    => 0,
+      nr_key         => "donumber",
+      transfer       => 'out',
+      transnumber    => 'sudonumber',
+    },
+    part_classification_query => [ "used_for_purchase" => 1 ],
+    right => "purchase_delivery_order_edit",
+  },
+  RMA_DELIVERY_ORDER_TYPE() => {
+    text => {
+      delete => t8('Delivery Order has been deleted'),
+      saved  => t8('Delivery Order has been saved'),
+      add    => t8("Add RMA Delivery Order"),
+      edit   => t8("Edit RMA Delivery Order"),
+      attachment => t8("rma_delivery_order_list"),
+    },
+    show_menu => {
+      save_and_quotation      => 0,
+      save_and_rfq            => 0,
+      save_and_sales_order    => 0,
+      save_and_purchase_order => 0,
+      save_and_delivery_order => 0,
+      save_and_ap_transaction => 0,
+      save_and_invoice        => 0,
+      delete                  => sub { $::instance_conf->get_sales_delivery_order_show_delete },
+      new_controller          => 1,
+    },
+    properties => {
+      customervendor => "customer",
+      is_customer    => 1,
+      nr_key         => "donumber",
+      transfer       => 'in',
+      transnumber    => 'rdonumber',
+    },
+    part_classification_query => [ "used_for_sale" => 1 ],
+    right => "sales_delivery_order_edit",
+  },
+);
+
+my @valid_types = (
+  SALES_DELIVERY_ORDER_TYPE,
+  PURCHASE_DELIVERY_ORDER_TYPE,
+  SUPPLIER_DELIVERY_ORDER_TYPE,
+  RMA_DELIVERY_ORDER_TYPE,
+);
+
+my %valid_types = map { $_ => $_ } @valid_types;
+
+sub valid_types {
+  \@valid_types;
+}
+
+sub is_valid_type {
+  !!exists $type_data{$_[0]};
+}
+
+sub validate_type {
+  my ($type) = @_;
+
+  return $valid_types{$type} // croak "invalid type '$type'";
+}
+
+sub get {
+  my ($type, $key) = @_;
+
+  croak "invalid type '$type'" unless exists $type_data{$type};
+
+  my $ret = $type_data{$type}->{$key} // die "unknown property '$key'";
+
+  ref $ret eq 'CODE'
+    ? $ret->()
+    : $ret;
+}
+
+sub get3 {
+  my ($type, $topic, $key) = @_;
+
+  croak "invalid type '$type'" unless exists $type_data{$type};
+
+  my $ret = $type_data{$type}{$topic}{$key} // croak "unknown property '$key' in topic '$topic' for type '$type'";
+
+  ref $ret eq 'CODE'
+    ? $ret->()
+    : $ret;
+}
+
+1;
index 045e2e6..7850a64 100644 (file)
@@ -48,6 +48,12 @@ sub displayable_delivery_order_info {
          . " (" . $self->delivery_order->transdate->to_kivitendo . ")";
 };
 
+sub effective_project {
+  my ($self) = @_;
+
+  $self->project // $self->delivery_order->globalproject;
+}
+
 __END__
 
 =pod
index 97fb2a1..dc6f00d 100644 (file)
@@ -13,6 +13,11 @@ __PACKAGE__->meta->add_relationship(
     class        => 'SL::DB::Inventory',
     column_map   => { id => 'delivery_order_items_stock_id' },
   },
+  unit_obj => {
+    type         => 'many to one',
+    class        => 'SL::DB::Unit',
+    column_map   => { unit => 'name' },
+  },
 );
 
 __PACKAGE__->meta->initialize;
index 5b45853..eb89ea8 100644 (file)
@@ -30,6 +30,8 @@ my %specs = ( ar                      => { number_column => 'invnumber',
               purchase_order          => { number_column => 'ordnumber',      number_range_column => 'ponumber',       scoping => \&oe_scoping,    },
               sales_delivery_order    => { number_column => 'donumber',       number_range_column => 'sdonumber',      scoping => \&do_scoping,    },
               purchase_delivery_order => { number_column => 'donumber',       number_range_column => 'pdonumber',      scoping => \&do_scoping,    },
+              supplier_delivery_order => { number_column => 'donumber',       number_range_column => 'sudonumber',     scoping => \&do_scoping,    },
+              rma_delivery_order      => { number_column => 'donumber',       number_range_column => 'rdonumber',      scoping => \&do_scoping,    },
               customer                => { number_column => 'customernumber', number_range_column => 'customernumber',                             },
               vendor                  => { number_column => 'vendornumber',   number_range_column => 'vendornumber',                               },
               part                    => { number_column => 'partnumber',     number_range_column => 'articlenumber',  scoping => \&parts_scoping, },
index a463db5..8557719 100644 (file)
@@ -4,15 +4,81 @@
 package SL::DB::Inventory;
 
 use strict;
+use Carp;
+use DateTime;
 
+use SL::DBUtils qw(selectrow_query);
 use SL::DB::MetaSetup::Inventory;
 use SL::DB::Manager::Inventory;
 
 __PACKAGE__->meta->initialize;
 
+__PACKAGE__->before_save(\&_before_save_create_trans_id);
+__PACKAGE__->before_save(\&_before_save_set_shippingdate);
+__PACKAGE__->before_save(\&_before_save_set_employee);
+
 # part accessor is badly named
 sub part {
   goto &parts;
 }
 
+sub new_from {
+  my ($class, $obj) = @_;
+
+  if ('SL::DB::DeliveryOrderItemsStock' eq ref $obj) {
+    return $class->new_from_delivery_order_stock($obj);
+  }
+
+  croak "unknown obj type (@{[ ref $obj ]}) for SL::DB::Inventory::new_from";
+}
+
+sub new_from_delivery_order_stock {
+  my ($class, $stock) = @_;
+
+  my $project = $stock->delivery_order_item->effective_project;
+
+  return $class->new(
+    delivery_order_items_stock_id => $stock->id,
+    parts_id                      => $stock->delivery_order_item->parts_id,
+    qty                           => $stock->unit_obj->convert_to($stock->qty => $stock->delivery_order_item->part->unit_obj),
+    warehouse_id                  => $stock->warehouse_id,
+    bin_id                        => $stock->bin_id,
+    chargenumber                  => $stock->chargenumber,
+    bestbefore                    => $stock->bestbefore,
+    project_id                    => $project ? $project->id : undef,
+    # trans_type - not set here, set in controller
+  );
+}
+
+sub _before_save_create_trans_id {
+  my ($self, %params) = @_;
+
+  return 1 if $self->trans_id;
+
+  my ($trans_id) = selectrow_query($::form, SL::DB->client->dbh, qq|SELECT nextval('id')|);
+
+  $self->trans_id($trans_id);
+
+  return 1;
+}
+
+sub _before_save_set_shippingdate {
+  my ($self, %params) = @_;
+
+  return 1 if $self->shippingdate;
+
+  $self->shippingdate(DateTime->now);
+
+  return 1;
+}
+
+sub _before_save_set_employee {
+  my ($self, %params) = @_;
+
+  return 1 if $self->employee_id;
+
+  $self->employee(SL::DB::Manager::Employee->current);
+
+  return 1;
+}
 1;
index 156ca7b..e9c1ee7 100644 (file)
@@ -8,6 +8,8 @@ use SL::DB::Helper::Paginated;
 use SL::DB::Helper::Sorted;
 use SL::DB::Helper::Filtered;
 
+use SL::DB::DeliveryOrder::TypeData qw(validate_type);
+
 sub object_class { 'SL::DB::DeliveryOrder' }
 
 __PACKAGE__->make_manager_methods;
@@ -26,11 +28,9 @@ __PACKAGE__->add_filter_specs(
 sub type_filter {
   my $class = shift;
   my $type  = lc(shift || '');
+  my $prefix = shift // '';
 
-  return ('!customer_id' => undef) if $type eq 'sales_delivery_order';
-  return ('!vendor_id'   => undef) if $type eq 'purchase_delivery_order';
-
-  die "Unknown type $type";
+  return "${prefix}order_type" => validate_type($type);
 }
 
 sub _sort_spec {
index b7fe2b1..ba8cf32 100644 (file)
@@ -148,6 +148,7 @@ __PACKAGE__->meta->columns(
   purchase_delivery_order_show_delete       => { type => 'boolean', default => 'true' },
   purchase_order_show_delete                => { type => 'boolean', default => 'true' },
   quick_search_modules                      => { type => 'array' },
+  rdonumber                                 => { type => 'text' },
   reqdate_interval                          => { type => 'integer', default => '0' },
   reqdate_on                                => { type => 'boolean', default => 'true' },
   require_transaction_description_ps        => { type => 'boolean', default => 'false', not_null => 1 },
@@ -182,6 +183,7 @@ __PACKAGE__->meta->columns(
   stocktaking_cutoff_date                   => { type => 'date' },
   stocktaking_qty_threshold                 => { type => 'numeric', default => '0', precision => 25, scale => 5 },
   stocktaking_warehouse_id                  => { type => 'integer' },
+  sudonumber                                => { type => 'text' },
   taxnumber                                 => { type => 'text' },
   templates                                 => { type => 'text' },
   transfer_default                          => { type => 'boolean', default => 'true' },
index f24fc9f..994fa96 100644 (file)
@@ -23,11 +23,11 @@ __PACKAGE__->meta->columns(
   globalproject_id        => { type => 'integer' },
   id                      => { type => 'integer', not_null => 1, sequence => 'id' },
   intnotes                => { type => 'text' },
-  is_sales                => { type => 'boolean' },
   itime                   => { type => 'timestamp', default => 'now()' },
   language_id             => { type => 'integer' },
   mtime                   => { type => 'timestamp' },
   notes                   => { type => 'text' },
+  order_type              => { type => 'text', not_null => 1 },
   ordnumber               => { type => 'text' },
   oreqnumber              => { type => 'text' },
   payment_id              => { type => 'integer' },
index 86c65ea..ac3f34b 100644 (file)
--- a/SL/DO.pm
+++ b/SL/DO.pm
@@ -42,6 +42,7 @@ use SL::AM;
 use SL::Common;
 use SL::CVar;
 use SL::DB::DeliveryOrder;
+use SL::DB::DeliveryOrder::TypeData qw(:types is_valid_type);
 use SL::DB::Status;
 use SL::DBUtils;
 use SL::Helper::ShippedQty;
@@ -79,6 +80,7 @@ sub transactions {
          dord.transaction_description, dord.itime::DATE AS insertdate,
          pr.projectnumber AS globalprojectnumber,
          dep.description AS department,
+         dord.order_type,
          e.name AS employee,
          sm.name AS salesman
        FROM delivery_orders dord
@@ -90,7 +92,10 @@ sub transactions {
        LEFT JOIN department dep ON (dord.department_id = dep.id)
 |;
 
-  push @where, ($form->{type} eq 'sales_delivery_order' ? '' : 'NOT ') . qq|COALESCE(dord.is_sales, FALSE)|;
+  if ($form->{type} && is_valid_type($form->{type})) {
+    push @where, 'dord.order_type = ?';
+    push @values, $form->{type};
+  }
 
   if ($form->{department_id}) {
     push @where,  qq|dord.department_id = ?|;
@@ -328,8 +333,8 @@ sub _save {
     $query = qq|SELECT nextval('id')|;
     ($form->{id}) = selectrow_query($form, $dbh, $query);
 
-    $query = qq|INSERT INTO delivery_orders (id, donumber, employee_id, currency_id, taxzone_id) VALUES (?, '', ?, (SELECT currency_id FROM defaults LIMIT 1), ?)|;
-    do_query($form, $dbh, $query, $form->{id}, conv_i($form->{employee_id}), $form->{taxzone_id});
+    $query = qq|INSERT INTO delivery_orders (id, donumber, employee_id, currency_id, taxzone_id, order_type) VALUES (?, '', ?, (SELECT currency_id FROM defaults LIMIT 1), ?, ?)|;
+    do_query($form, $dbh, $query, $form->{id}, conv_i($form->{employee_id}), $form->{taxzone_id}, SALES_DELIVERY_ORDER_TYPE);
   }
 
   my $project_id;
@@ -515,7 +520,7 @@ SQL
          shippingpoint = ?, shipvia = ?, notes = ?, intnotes = ?, closed = ?,
          delivered = ?, department_id = ?, language_id = ?, shipto_id = ?, billing_address_id = ?,
          globalproject_id = ?, employee_id = ?, salesman_id = ?, cp_id = ?, transaction_description = ?,
-         is_sales = ?, taxzone_id = ?, taxincluded = ?, payment_id = ?, currency_id = (SELECT id FROM currencies WHERE name = ?),
+         order_type = ?, taxzone_id = ?, taxincluded = ?, payment_id = ?, currency_id = (SELECT id FROM currencies WHERE name = ?),
          delivery_term_id = ?
        WHERE id = ?|;
 
@@ -529,7 +534,7 @@ SQL
              conv_i($form->{globalproject_id}), conv_i($form->{employee_id}),
              conv_i($form->{salesman_id}), conv_i($form->{cp_id}),
              $form->{transaction_description},
-             $form->{type} =~ /^sales/ ? 't' : 'f',
+             $form->{type} =~ /^sales/ ? SALES_DELIVERY_ORDER_TYPE : PURCHASE_DELIVERY_ORDER_TYPE,
              conv_i($form->{taxzone_id}), $form->{taxincluded} ? 't' : 'f', conv_i($form->{payment_id}), $form->{currency},
              conv_i($form->{delivery_term_id}),
              conv_i($form->{id}));
index 2f289d0..73f06d0 100644 (file)
@@ -20,6 +20,7 @@ our %EXPORT_TAGS = (ALL => \@EXPORT_OK);
 
 use SL::DB::Invoice;
 use SL::DB::InvoiceItem;
+use SL::DB::DeliveryOrder::TypeData qw(:types);
 use SL::DB::Employee;
 use SL::Dev::Part qw(new_part);
 use SL::Dev::CustomerVendor qw(new_vendor new_customer);
@@ -116,7 +117,7 @@ sub create_sales_delivery_order {
   die "illegal customer" unless ref($customer) eq 'SL::DB::Customer';
 
   my $delivery_order = SL::DB::DeliveryOrder->new(
-    'is_sales'   => 'true',
+    order_type   => SALES_DELIVERY_ORDER_TYPE,
     'closed'     => undef,
     customer_id  => $customer->id,
     taxzone_id   => $customer->taxzone_id,
@@ -144,7 +145,7 @@ sub create_purchase_delivery_order {
   die "illegal customer" unless ref($vendor) eq 'SL::DB::Vendor';
 
   my $delivery_order = SL::DB::DeliveryOrder->new(
-    'is_sales'   => 'false',
+    order_type   => PURCHASE_DELIVERY_ORDER_TYPE,
     'closed'     => undef,
     vendor_id    => $vendor->id,
     taxzone_id   => $vendor->taxzone_id,
index 62ebe73..e0bff1b 100644 (file)
@@ -1100,6 +1100,8 @@ sub get_formname_translation {
     storno_invoice              => $main::locale->text('Storno Invoice'),
     sales_delivery_order        => $main::locale->text('Delivery Order'),
     purchase_delivery_order     => $main::locale->text('Delivery Order'),
+    supplier_delivery_order     => $main::locale->text('Supplier Delivery Order'),
+    rma_delivery_order          => $main::locale->text('RMA Delivery Order'),
     dunning                     => $main::locale->text('Dunning'),
     dunning1                    => $main::locale->text('Payment Reminder'),
     dunning2                    => $main::locale->text('Dunning'),
index 0a0ef15..39a69fc 100644 (file)
@@ -3,6 +3,7 @@ package SL::Helper::Number;
 use strict;
 use Exporter qw(import);
 use List::Util qw(max min);
+use List::UtilsBy qw(rev_nsort_by);
 use Config;
 
 our @EXPORT_OK = qw(
index 2b07ee7..9108a22 100644 (file)
@@ -75,7 +75,10 @@ sub get_print_options {
     ($form->{type} eq 'final_invoice') ? (
       opthash("final_invoice", $form->{PD}{final_invoice},             $locale->text('Final Invoice')),
     ) : undef,
-    ($form->{type} =~ /_delivery_order$/) ? (
+    ($form->{type} =~ /^supplier_delivery_order$/) ? (
+      opthash('supplier_delivery_order', $form->{PD}{supplier_delivery_order},  $locale->text('Supplier Delivery Order')),
+    ) : undef,
+    ($form->{type} =~ /(sales|purchase)_delivery_order$/) ? (
       opthash($form->{type},         $form->{PD}{$form->{type}},       $locale->text('Delivery Order')),
       opthash('pick_list',           $form->{PD}{pick_list},           $locale->text('Pick List')),
     ) : undef,
index 79123b1..9f525b3 100644 (file)
@@ -2,10 +2,12 @@ package SL::Presenter::DeliveryOrder;
 
 use strict;
 
+use SL::DB::DeliveryOrder::TypeData ();
+use SL::Locale::String qw(t8);
 use SL::Presenter::EscapedText qw(escape is_escaped);
 
 use Exporter qw(import);
-our @EXPORT_OK = qw(sales_delivery_order purchase_delivery_order);
+our @EXPORT_OK = qw(sales_delivery_order purchase_delivery_order delivery_order_status_line);
 
 use Carp;
 
@@ -15,12 +17,39 @@ sub sales_delivery_order {
   return _do_record($delivery_order, 'sales_delivery_order', %params);
 }
 
+sub rma_delivery_order {
+  my ($delivery_order, %params) = @_;
+
+  return _do_new_record($delivery_order, 'rma_delivery_order', %params);
+}
+
 sub purchase_delivery_order {
   my ($delivery_order, %params) = @_;
 
   return _do_record($delivery_order, 'purchase_delivery_order', %params);
 }
 
+sub supplier_delivery_order {
+  my ($delivery_order, %params) = @_;
+
+  return _do_new_record($delivery_order, 'supplier_delivery_order', %params);
+}
+
+sub _do_new_record {
+  my ($delivery_order, $type, %params) = @_;
+
+  $params{display} ||= 'inline';
+
+  croak "Unknown display type '$params{display}'" unless $params{display} =~ m/^(?:inline|table-cell)$/;
+
+  my $text = join '', (
+    $params{no_link} ? '' : '<a href="contoller.pl?action=DeliveryOrder/edit&amp;type=' . $type . '&amp;id=' . escape($delivery_order->id) . '">',
+    escape($delivery_order->donumber),
+    $params{no_link} ? '' : '</a>',
+  );
+  is_escaped($text);
+}
+
 sub _do_record {
   my ($delivery_order, $type, %params) = @_;
 
@@ -36,6 +65,36 @@ sub _do_record {
   is_escaped($text);
 }
 
+sub stock_status {
+  my ($delivery_order) = @_;
+
+  my $in_out = SL::DB::DeliveryOrder::TypeData::get3($delivery_order->type, "properties", "transfer");
+
+  if ($in_out eq 'in') {
+    return escape($delivery_order->delivered ? t8('transferred in') : t8('not transferred in yet'));
+  }
+
+  if ($in_out eq 'out') {
+    return escape($delivery_order->delivered ? t8('transferred out') : t8('not transferred out yet'));
+  }
+}
+
+sub closed_status {
+  my ($delivery_order) = @_;
+
+  return escape($delivery_order->closed ? t8('Closed') : t8('Open'))
+}
+
+sub status_line {
+  my ($delivery_order) = @_;
+
+  return "" unless $delivery_order->id;
+
+  stock_status($delivery_order) . " ; " . closed_status($delivery_order)
+}
+
+sub delivery_order_status_line { goto &status_line };
+
 1;
 
 __END__
index db68fa6..592af55 100644 (file)
@@ -48,12 +48,14 @@ sub grouped_record_list {
   $output .= _sales_quotation_list(        $groups{sales_quotations},         %params) if $groups{sales_quotations};
   $output .= _sales_order_list(            $groups{sales_orders},             %params) if $groups{sales_orders};
   $output .= _sales_delivery_order_list(   $groups{sales_delivery_orders},    %params) if $groups{sales_delivery_orders};
+  $output .= _rma_delivery_order_list(     $groups{rma_delivery_orders},      %params) if $groups{rma_delivery_orders};
   $output .= _sales_invoice_list(          $groups{sales_invoices},           %params) if $groups{sales_invoices};
   $output .= _ar_transaction_list(         $groups{ar_transactions},          %params) if $groups{ar_transactions};
 
   $output .= _request_quotation_list(      $groups{purchase_quotations},      %params) if $groups{purchase_quotations};
   $output .= _purchase_order_list(         $groups{purchase_orders},          %params) if $groups{purchase_orders};
   $output .= _purchase_delivery_order_list($groups{purchase_delivery_orders}, %params) if $groups{purchase_delivery_orders};
+  $output .= _supplier_delivery_order_list($groups{supplier_delivery_orders}, %params) if $groups{supplier_delivery_orders};
   $output .= _purchase_invoice_list(       $groups{purchase_invoices},        %params) if $groups{purchase_invoices};
   $output .= _ap_transaction_list(         $groups{ap_transactions},          %params) if $groups{ap_transactions};
 
@@ -183,12 +185,14 @@ sub _group_records {
     shop_orders              => sub { (ref($_[0]) eq 'SL::DB::ShopOrder')       &&  $_[0]->id                           },
     sales_quotations         => sub { (ref($_[0]) eq 'SL::DB::Order')           &&  $_[0]->is_type('sales_quotation')   },
     sales_orders             => sub { (ref($_[0]) eq 'SL::DB::Order')           &&  $_[0]->is_type('sales_order')       },
-    sales_delivery_orders    => sub { (ref($_[0]) eq 'SL::DB::DeliveryOrder')   &&  $_[0]->is_sales                     },
+    sales_delivery_orders    => sub { (ref($_[0]) eq 'SL::DB::DeliveryOrder')   &&  $_[0]->is_type('sales_delivery_order') },
+    rma_delivery_orders      => sub { (ref($_[0]) eq 'SL::DB::DeliveryOrder')   &&  $_[0]->is_type('rma_delivery_order')   },
     sales_invoices           => sub { (ref($_[0]) eq 'SL::DB::Invoice')         &&  $_[0]->invoice                      },
     ar_transactions          => sub { (ref($_[0]) eq 'SL::DB::Invoice')         && !$_[0]->invoice                      },
     purchase_quotations      => sub { (ref($_[0]) eq 'SL::DB::Order')           &&  $_[0]->is_type('request_quotation') },
     purchase_orders          => sub { (ref($_[0]) eq 'SL::DB::Order')           &&  $_[0]->is_type('purchase_order')    },
-    purchase_delivery_orders => sub { (ref($_[0]) eq 'SL::DB::DeliveryOrder')   && !$_[0]->is_sales                     },
+    purchase_delivery_orders => sub { (ref($_[0]) eq 'SL::DB::DeliveryOrder')   &&  $_[0]->is_type('purchase_delivery_order') },
+    supplier_delivery_orders => sub { (ref($_[0]) eq 'SL::DB::DeliveryOrder')   &&  $_[0]->is_type('supplier_delivery_order') },
     purchase_invoices        => sub { (ref($_[0]) eq 'SL::DB::PurchaseInvoice') &&  $_[0]->invoice                      },
     ap_transactions          => sub { (ref($_[0]) eq 'SL::DB::PurchaseInvoice') && !$_[0]->invoice                      },
     sepa_collections         => sub { (ref($_[0]) eq 'SL::DB::SepaExportItem')  &&  $_[0]->ar_id                        },
@@ -364,6 +368,27 @@ sub _sales_delivery_order_list {
   );
 }
 
+sub _rma_delivery_order_list {
+  my ($list, %params) = @_;
+
+  return record_list(
+    $list,
+    title   => $::locale->text('RMA Delivery Orders'),
+    type    => 'rma_delivery_order',
+    columns => [
+      [ $::locale->text('Delivery Order Date'),     'transdate'                                                                ],
+      [ $::locale->text('Delivery Order Number'),   sub { $_[0]->presenter->rma_delivery_order(display => 'table-cell') }    ],
+      [ $::locale->text('Order Number'),            'ordnumber' ],
+      [ $::locale->text('Customer'),                'customer'                                                                 ],
+      [ $::locale->text('Transaction description'), 'transaction_description'                                                  ],
+      [ $::locale->text('Project'),                 'globalproject', ],
+      [ $::locale->text('Delivered'),               'delivered'                                                                ],
+      [ $::locale->text('Closed'),                  'closed'                                                                   ],
+    ],
+    %params,
+  );
+}
+
 sub _purchase_delivery_order_list {
   my ($list, %params) = @_;
 
@@ -385,6 +410,27 @@ sub _purchase_delivery_order_list {
   );
 }
 
+sub _supplier_delivery_order_list {
+  my ($list, %params) = @_;
+
+  return record_list(
+    $list,
+    title   => $::locale->text('Supplier Delivery Orders'),
+    type    => 'supplier_delivery_order',
+    columns => [
+      [ $::locale->text('Delivery Order Date'),     'transdate'                                                                ],
+      [ $::locale->text('Delivery Order Number'),   sub { $_[0]->presenter->supplier_delivery_order(display => 'table-cell') } ],
+      [ $::locale->text('Order Number'),            'ordnumber' ],
+      [ $::locale->text('Vendor'),                  'vendor'                                                                 ],
+      [ $::locale->text('Transaction description'), 'transaction_description'                                                  ],
+      [ $::locale->text('Project'),                 'globalproject', ],
+      [ $::locale->text('Delivered'),               'delivered'                                                                ],
+      [ $::locale->text('Closed'),                  'closed'                                                                   ],
+    ],
+    %params,
+  );
+}
+
 sub _sales_invoice_list {
   my ($list, %params) = @_;
 
@@ -662,6 +708,8 @@ The order in which the records are grouped is:
 
 =item * sales delivery orders
 
+=item * rma delivery orders
+
 =item * sales invoices
 
 =item * AR transactions
@@ -672,6 +720,8 @@ The order in which the records are grouped is:
 
 =item * purchase delivery orders
 
+=item * supplier delivery orders
+
 =item * purchase invoices
 
 =item * AP transactions
index 25646da..8e87d3b 100644 (file)
@@ -9,13 +9,16 @@ use List::MoreUtils qw(any none);
 use SL::DBUtils;
 use SL::PrefixedNumber;
 use SL::DB;
+use SL::DB::DeliveryOrder::TypeData;
 
 use Rose::Object::MakeMethods::Generic
 (
  scalar => [ qw(type id number save dbh dbh_provided business_id) ],
 );
 
-my @SUPPORTED_TYPES = qw(invoice invoice_for_advance_payment final_invoice credit_note customer vendor sales_delivery_order purchase_delivery_order sales_order purchase_order sales_quotation request_quotation part service assembly assortment letter);
+my @SUPPORTED_TYPES = qw(invoice invoice_for_advance_payment final_invoice credit_note customer vendor sales_delivery_order purchase_delivery_order sales_order purchase_order sales_quotation request_quotation part service assembly assortment letter),
+  @{ SL::DB::DeliveryOrder::TypeData::valid_types() },
+);
 
 sub new {
   my $class = shift;
@@ -47,11 +50,12 @@ sub _get_filters {
     $filters{numberfield}   = "${type}number";
     $filters{table}         = $type;
 
-  } elsif ($type =~ /_delivery_order$/) {
-    $filters{trans_number}  = "donumber";
-    $filters{numberfield}   = $type eq 'sales_delivery_order' ? "sdonumber" : "pdonumber";
+  } elsif ($type =~ /_delivery_order$/ && SL::DB::DeliveryOrder::TypeData::is_valid_type($type)) {
+    $filters{trans_number}  = SL::DB::DeliveryOrder::TypeData::get3($type, 'properties', 'nr_key'),
+    $filters{numberfield}   = SL::DB::DeliveryOrder::TypeData::get3($type, 'properties', 'transnumber'),
     $filters{table}         = "delivery_orders";
-    $filters{where}         = $type =~ /^sales/ ? '(customer_id IS NOT NULL)' : '(vendor_id IS NOT NULL)';
+    $filters{where}         = "order_type = ?";
+    $filters{values}        = [ $::form->{type} ];
 
   } elsif ($type =~ /_order$/) {
     $filters{trans_number}  = "ordnumber";
@@ -96,6 +100,7 @@ sub is_unique {
   my @values = ($self->number);
 
   push @where, $filters{where} if $filters{where};
+  push @values, @{ $filters{values} } if $filters{values};
 
   if ($self->id) {
     push @where,  qq|id <> ?|;
@@ -131,7 +136,7 @@ sub create_unique {
 SQL
 
     do_query($form, $self->dbh, "LOCK TABLE " . $filters{table}) || die $self->dbh->errstr;
-    my %numbers_in_use = selectall_as_map($form, $self->dbh, $query, $filters{trans_number}, 'in_use');
+    my %numbers_in_use = selectall_as_map($form, $self->dbh, $query, $filters{trans_number}, 'in_use', @{ $filters{values} // [] });
 
     my $business_number;
     ($business_number) = selectfirst_array_query($form, $self->dbh, qq|SELECT customernumberinit FROM business WHERE id = ? FOR UPDATE|, $self->business_id) if $self->business_id;
index b0f8909..85f6249 100644 (file)
@@ -23,6 +23,8 @@ my %type_to_path = (
   purchase_order              => 'lieferantenbestellungen',
   sales_delivery_order        => 'verkaufslieferscheine',
   purchase_delivery_order     => 'einkaufslieferscheine',
+  supplier_delivery_order     => 'beistelllieferscheine',
+  rma_delivery_order          => 'retourenlieferscheine',
   credit_note                 => 'gutschriften',
   invoice                     => 'rechnungen',
   invoice_for_advance_payment => 'rechnungen',
index 923ec38..7a51a6a 100644 (file)
@@ -36,7 +36,9 @@ use List::MoreUtils qw(uniq);
 use List::Util qw(max sum);
 use POSIX qw(strftime);
 
+use SL::Controller::DeliveryOrder;
 use SL::DB::DeliveryOrder;
+use SL::DB::DeliveryOrder::TypeData qw(:types validate_type);
 use SL::DO;
 use SL::IR;
 use SL::IS;
@@ -56,7 +58,10 @@ use strict;
 # end of main
 
 sub check_do_access {
-  $main::auth->assert($main::form->{type} . '_edit');
+  validate_type($::form->{type});
+
+  my $right = SL::DB::DeliveryOrder::TypeData::get($::form->{type}, "right");
+  $main::auth->assert($right);
 }
 
 sub set_headings {
@@ -887,7 +892,9 @@ sub orders {
       'align'    => 'center',
     };
 
-    $row->{donumber}->{link}  = $edit_url       . "&id=" . E($dord->{id})      . "&callback=${callback}";
+    $row->{donumber}->{link}  = SL::DB::DeliveryOrder::TypeData::get3($dord->{order_type}, "show_menu", "new_controller")
+                              ? SL::Controller::DeliveryOrder->url_for(action => "edit", id => $dord->{id}, type => $dord->{order_type})
+                              : $edit_url  . "&id=" . E($dord->{id})      . "&callback=${callback}";
     $row->{ordnumber}->{link} = $edit_order_url . "&id=" . E($dord->{oe_id})   . "&callback=${callback}" if $dord->{oe_id};
     $report->add_data($row);
 
index 2f0fb95..4b6cf26 100644 (file)
@@ -60,6 +60,7 @@ use SL::DB::AuthUser;
 use SL::DB::Contact;
 use SL::DB::Currency;
 use SL::DB::Customer;
+use SL::DB::DeliveryOrder::TypeData qw();
 use SL::DB::Default;
 use SL::DB::Language;
 use SL::DB::Printer;
@@ -2018,7 +2019,10 @@ sub _make_record {
   }
 
   $obj->items(@items) if @items;
-  $obj->is_sales(!!$obj->customer_id) if $class eq 'SL::DB::DeliveryOrder';
+
+  if ($class eq 'SL::DB::DeliveryOrder' && !$obj->order_type) {
+    $obj->order_type(SL::DB::DeliveryOrder::TypeData::validate_type($::form->{type}));
+  }
 
   if ($class eq 'SL::DB::Invoice') {
     my $paid = $factor *
diff --git a/js/kivi.DeliveryOrder.js b/js/kivi.DeliveryOrder.js
new file mode 100644 (file)
index 0000000..00a9294
--- /dev/null
@@ -0,0 +1,817 @@
+namespace('kivi.DeliveryOrder', function(ns) {
+  ns.check_cv = function() {
+    if ($('#type').val() == 'sales_delivery_order') {
+      if ($('#order_customer_id').val() === '') {
+        alert(kivi.t8('Please select a customer.'));
+        return false;
+      }
+    } else  {
+      if ($('#order_vendor_id').val() === '') {
+        alert(kivi.t8('Please select a vendor.'));
+        return false;
+      }
+    }
+    return true;
+  };
+
+  ns.check_duplicate_parts = function(question) {
+    var id_arr = $('[name="order.orderitems[].parts_id"]').map(function() { return this.value; }).get();
+
+    var i, obj = {}, pos = [];
+
+    for (i = 0; i < id_arr.length; i++) {
+      var id = id_arr[i];
+      if (obj.hasOwnProperty(id)) {
+        pos.push(i + 1);
+      }
+      obj[id] = 0;
+    }
+
+    if (pos.length > 0) {
+      question = question || kivi.t8("Do you really want to continue?");
+      return confirm(kivi.t8("There are duplicate parts at positions") + "\n"
+                     + pos.join(', ') + "\n"
+                     + question);
+    }
+    return true;
+  };
+
+  ns.check_valid_reqdate = function() {
+    if ($('#order_reqdate_as_date').val() === '') {
+      alert(kivi.t8('Please select a delivery date.'));
+      return false;
+    } else {
+      return true;
+    }
+  };
+
+  ns.save = function(action, warn_on_duplicates, warn_on_reqdate) {
+    if (!ns.check_cv()) return;
+    if (warn_on_duplicates && !ns.check_duplicate_parts()) return;
+    if (warn_on_reqdate    && !ns.check_valid_reqdate())   return;
+
+    var data = $('#order_form').serializeArray();
+    data.push({ name: 'action', value: 'DeliveryOrder/' + action });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.delete_order = function() {
+    var data = $('#order_form').serializeArray();
+    data.push({ name: 'action', value: 'DeliveryOrder/delete' });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.show_print_options = function(warn_on_duplicates, warn_on_reqdate) {
+    if (!ns.check_cv()) return;
+    if (warn_on_duplicates && !ns.check_duplicate_parts(kivi.t8("Do you really want to print?"))) return;
+    if (warn_on_reqdate    && !ns.check_valid_reqdate())   return;
+
+    kivi.popup_dialog({
+      id: 'print_options',
+      dialog: {
+        title: kivi.t8('Print options'),
+        width:  800,
+        height: 300
+      }
+    });
+  };
+
+  ns.open_stock_in_out_dialog = function(clicked, in_out) {
+    var $row = $(clicked).parents("tbody").first();
+    var id = $row.find('[name="orderitem_ids[+]"]').val();
+    $row.uniqueId();
+
+    kivi.popup_dialog({
+      id: "stock_in_out_dialog",
+      url: "controller.pl?action=DeliveryOrder/stock_in_out_dialog",
+      data: {
+        id:            $("#id").val(),
+        type:          $("#type").val(),
+        parts_id:      $row.find("[name$=parts_id]").val(),
+        unit:          $row.find("[name$=unit]").val(),
+        qty_as_number: $row.find("[name$=qty_as_number]").val(),
+        stock:         $row.find("[name$=stock_info]").val(),
+        item_id:       id,
+        row:           $row.attr("id"),
+      },
+      dialog: { title: kivi.t8('Transfer stock') }
+    });
+  };
+
+  ns.save_updated_stock = function() {
+    // stock information is saved in DOM as a yaml dump.
+    // we don't want to do this in javascript so we do a tiny roundtrip to the backend
+
+    let data = [];
+    $("#stock-in-out-table tr.listrow").each((i,row) => {
+      let qty = kivi.parse_amount($(row).find(".data-qty").val());
+
+      if (qty === 0) return;
+
+      data.push({
+        qty:                           qty,
+        warehouse_id:                  $(row).find(".data-warehouse-id").val(),
+        bin_id:                        $(row).find(".data-bin-id").val(),
+        chargenumber:                  $(row).find(".data-chargenumber").val(),
+        bestbefore:                    $(row).find(".data-bestbefore").val(),
+        unit:                          $(row).find(".data-unit").val(),
+        delivery_order_items_stock_id: $(row).find(".data-stock-id").val(),
+      });
+    });
+
+    let row = $(".data-row").val();
+
+    $.post("controller.pl",
+      kivi.serialize({
+        action:     "DeliveryOrder/update_stock_information",
+        unit:       $("#" + row).find("[name$=unit]").val(),
+        stock_info: data,
+        row:        row
+      }),
+      (data) => {
+        $("#" + row + " .data-stock-info").val(data.stock_info);
+        $("#" + row + " .data-stock-qty").text(data.stock_qty)
+        $("#stock_in_out_dialog").dialog("close");
+      }
+    );
+  };
+
+  ns.print = function() {
+    $('#print_options').dialog('close');
+
+    var data = $('#order_form').serializeArray();
+    data = data.concat($('#print_options_form').serializeArray());
+    data.push({ name: 'action', value: 'DeliveryOrder/print' });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  var email_dialog;
+
+  ns.setup_send_email_dialog = function() {
+    kivi.SalesPurchase.show_all_print_options_elements();
+    kivi.SalesPurchase.show_print_options_elements([ 'sendmode', 'media', 'copies', 'remove_draft' ], false);
+
+    $('#print_options_form table').first().remove().appendTo('#email_form_print_options');
+
+    var to_focus = $('#email_form_to').val() === '' ? 'to' : 'subject';
+    $('#email_form_' + to_focus).focus();
+  };
+
+  ns.finish_send_email_dialog = function() {
+    kivi.SalesPurchase.show_all_print_options_elements();
+
+    $('#email_form_print_options table').first().remove().prependTo('#print_options_form');
+    return true;
+  };
+
+  ns.show_email_dialog = function(html) {
+    var id            = 'send_email_dialog';
+    var dialog_params = {
+      id:     id,
+      width:  800,
+      height: 600,
+      title:  kivi.t8('Send email'),
+      modal:  true,
+      beforeClose: kivi.DeliveryOrder.finish_send_email_dialog,
+      close: function(event, ui) {
+        email_dialog.remove();
+      }
+    };
+
+    $('#' + id).remove();
+
+    email_dialog = $('<div style="display:none" id="' + id + '"></div>').appendTo('body');
+    email_dialog.html(html);
+    email_dialog.dialog(dialog_params);
+
+    kivi.DeliveryOrder.setup_send_email_dialog();
+
+    $('.cancel').click(ns.close_email_dialog);
+
+    return true;
+  };
+
+  ns.send_email = function() {
+    // push button only once -> slow response from mail server
+    ns.email_dialog_disable_send();
+
+    var data = $('#order_form').serializeArray();
+    data = data.concat($('[name^="email_form."]').serializeArray());
+    data = data.concat($('[name^="print_options."]').serializeArray());
+    data.push({ name: 'action', value: 'DeliveryOrder/send_email' });
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.email_dialog_disable_send = function() {
+    // disable mail send event to prevent
+    // impatient users to send multiple times
+    $('#send_email').prop('disabled', true);
+  };
+
+  ns.close_email_dialog = function() {
+    email_dialog.dialog("close");
+  };
+
+  ns.set_number_in_title = function(elt) {
+    $('#nr_in_title').html($(elt).val());
+  };
+
+  ns.reload_cv_dependent_selections = function() {
+    $('#order_shipto_id').val('');
+    var data = $('#order_form').serializeArray();
+    data.push({ name: 'action', value: 'DeliveryOrder/customer_vendor_changed' });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.reformat_number = function(event) {
+    $(event.target).val(kivi.format_amount(kivi.parse_amount($(event.target).val()), -2));
+  };
+
+  ns.reformat_number_as_null_number = function(event) {
+    if ($(event.target).val() === '') {
+      return;
+    }
+    ns.reformat_number(event);
+  };
+
+  ns.update_exchangerate = function(event) {
+    if (!ns.check_cv()) {
+      $('#order_currency_id').val($('#old_currency_id').val());
+      return;
+    }
+
+    var rate_input = $('#order_exchangerate_as_null_number');
+    // unset exchangerate if currency changed
+    if ($('#order_currency_id').val() !== $('#old_currency_id').val()) {
+      rate_input.val('');
+    }
+
+    // only set exchangerate if unset
+    if (rate_input.val() !== '') {
+      return;
+    }
+
+    var data = $('#order_form').serializeArray();
+    data.push({ name: 'action', value: 'DeliveryOrder/update_exchangerate' });
+
+    $.ajax({
+      url: 'controller.pl',
+      data: data,
+      method: 'POST',
+      dataType: 'json',
+      success: function(data){
+        if (!data.is_standard) {
+          $('#currency_name').text(data.currency_name);
+          if (data.exchangerate) {
+            rate_input.val(data.exchangerate);
+          } else {
+            rate_input.val('');
+          }
+          $('#exchangerate_settings').show();
+        } else {
+          rate_input.val('');
+          $('#exchangerate_settings').hide();
+        }
+        if ($('#order_currency_id').val() != $('#old_currency_id').val() ||
+            !data.is_standard && data.exchangerate != $('#old_exchangerate').val()) {
+          kivi.display_flash('warning', kivi.t8('You have changed the currency or exchange rate. Please check prices.'));
+        }
+        $('#old_currency_id').val($('#order_currency_id').val());
+        $('#old_exchangerate').val(data.exchangerate);
+      }
+    });
+  };
+
+  ns.exchangerate_changed = function(event) {
+    if (kivi.parse_amount($('#order_exchangerate_as_null_number').val()) != kivi.parse_amount($('#old_exchangerate').val())) {
+      kivi.display_flash('warning', kivi.t8('You have changed the currency or exchange rate. Please check prices.'));
+      $('#old_exchangerate').val($('#order_exchangerate_as_null_number').val());
+    }
+  };
+
+  ns.unit_change = function(event) {
+    var row           = $(event.target).parents("tbody").first();
+    var item_id_dom   = $(row).find('[name="orderitem_ids[+]"]');
+    var sellprice_dom = $(row).find('[name="order.orderitems[].sellprice_as_number"]');
+    var select_elt    = $(row).find('[name="order.orderitems[].unit"]');
+
+    var oldval = $(select_elt).data('oldval');
+    $(select_elt).data('oldval', $(select_elt).val());
+
+    var data = $('#order_form').serializeArray();
+    data.push({ name: 'action',           value: 'DeliveryOrder/unit_changed'     },
+              { name: 'item_id',          value: item_id_dom.val()        },
+              { name: 'old_unit',         value: oldval                   },
+              { name: 'sellprice_dom_id', value: sellprice_dom.attr('id') });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.update_sellprice = function(item_id, price_str) {
+    var row       = $('#item_' + item_id).parents("tbody").first();
+    var price_elt = $(row).find('[name="order.orderitems[].sellprice_as_number"]');
+    var html_elt  = $(row).find('[name="sellprice_text"]');
+    price_elt.val(price_str);
+    html_elt.html(price_str);
+  };
+
+  ns.load_second_row = function(row) {
+    var item_id_dom = $(row).find('[name="orderitem_ids[+]"]');
+    var div_elt     = $(row).find('[name="second_row"]');
+
+    if ($(div_elt).data('loaded') == 1) {
+      return;
+    }
+    var data = $('#order_form').serializeArray();
+    data.push({ name: 'action',     value: 'DeliveryOrder/load_second_rows' },
+              { name: 'item_ids[]', value: item_id_dom.val()        });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.load_all_second_rows = function() {
+    var rows = $('.row_entry').filter(function(idx, elt) {
+      return $(elt).find('[name="second_row"]').data('loaded') != 1;
+    });
+
+    var item_ids = $.map(rows, function(elt) {
+      var item_id = $(elt).find('[name="orderitem_ids[+]"]').val();
+      return { name: 'item_ids[]', value: item_id };
+    });
+
+    if (item_ids.length == 0) {
+      return;
+    }
+
+    var data = $('#order_form').serializeArray();
+    data.push({ name: 'action', value: 'DeliveryOrder/load_second_rows' });
+    data = data.concat(item_ids);
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.hide_second_row = function(row) {
+    $(row).children().not(':first').hide();
+    $(row).data('expanded', 0);
+    var elt = $(row).find('.expand');
+    elt.attr('src', "image/expand.svg");
+    elt.attr('alt', kivi.t8('Show details'));
+    elt.attr('title', kivi.t8('Show details'));
+  };
+
+  ns.show_second_row = function(row) {
+    $(row).children().not(':first').show();
+    $(row).data('expanded', 1);
+    var elt = $(row).find('.expand');
+    elt.attr('src', "image/collapse.svg");
+    elt.attr('alt', kivi.t8('Hide details'));
+    elt.attr('title', kivi.t8('Hide details'));
+  };
+
+  ns.toggle_second_row = function(row) {
+    if ($(row).data('expanded') == 1) {
+      ns.hide_second_row(row);
+    } else {
+      ns.show_second_row(row);
+    }
+  };
+
+  ns.init_row_handlers = function() {
+    kivi.run_once_for('.reformat_number', 'on_change_reformat', function(elt) {
+      $(elt).change(ns.reformat_number);
+    });
+
+    kivi.run_once_for('.unitselect', 'on_change_unit_with_oldval', function(elt) {
+      $(elt).data('oldval', $(elt).val());
+      $(elt).change(ns.unit_change);
+    });
+
+    kivi.run_once_for('.row_entry', 'on_kbd_click_show_hide', function(elt) {
+      $(elt).keydown(function(event) {
+        var row;
+        if (event.keyCode == 40 && event.shiftKey === true) {
+          // shift arrow down
+          event.preventDefault();
+          row = $(event.target).parents(".row_entry").first();
+          ns.load_second_row(row);
+          ns.show_second_row(row);
+          return false;
+        }
+        if (event.keyCode == 38 && event.shiftKey === true) {
+          // shift arrow up
+          event.preventDefault();
+          row = $(event.target).parents(".row_entry").first();
+          ns.hide_second_row(row);
+          return false;
+        }
+      });
+    });
+
+    kivi.run_once_for('.expand', 'expand_second_row', function(elt) {
+      $(elt).click(function(event) {
+        event.preventDefault();
+        var row = $(event.target).parents(".row_entry").first();
+        ns.load_second_row(row);
+        ns.toggle_second_row(row);
+        return false;
+      })
+    });
+
+  };
+
+  ns.redisplay_line_values = function(is_sales, data) {
+    $('.row_entry').each(function(idx, elt) {
+      $(elt).find('[name="linetotal"]').html(data[idx][0]);
+      if (is_sales && $(elt).find('[name="second_row"]').data('loaded') == 1) {
+        var mt = data[idx][1];
+        var mp = data[idx][2];
+        var h  = '<span';
+        if (mt[0] === '-') h += ' class="plus0"';
+        h += '>' + mt + '&nbsp;&nbsp;' + mp + '%';
+        h += '</span>';
+        $(elt).find('[name="linemargin"]').html(h);
+      }
+    });
+  };
+
+  ns.redisplay_cvpartnumbers = function(data) {
+    $('.row_entry').each(function(idx, elt) {
+      $(elt).find('[name="cvpartnumber"]').html(data[idx][0]);
+    });
+  };
+
+  ns.renumber_positions = function() {
+    $('.row_entry [name="position"]').each(function(idx, elt) {
+      $(elt).html(idx+1);
+    });
+    $('.row_entry').each(function(idx, elt) {
+      $(elt).data("position", idx+1);
+    });
+  };
+
+  ns.reorder_items = function(order_by) {
+    var dir = $('#' + order_by + '_header_id a img').attr("data-sort-dir");
+    $('#row_table_id thead a img').remove();
+
+    var src;
+    if (dir == "1") {
+      dir = "0";
+      src = "image/up.png";
+    } else {
+      dir = "1";
+      src = "image/down.png";
+    }
+
+    $('#' + order_by + '_header_id a').append('<img border=0 data-sort-dir=' + dir + ' src=' + src + ' alt="' + kivi.t8('sort items') + '">');
+
+    var data = $('#order_form').serializeArray();
+    data.push({ name: 'action',   value: 'DeliveryOrder/reorder_items' },
+              { name: 'order_by', value: order_by              },
+              { name: 'sort_dir', value: dir                   });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.redisplay_items = function(data) {
+    var old_rows = $('.row_entry').detach();
+    var new_rows = [];
+    $(data).each(function(idx, elt) {
+      new_rows.push(old_rows[elt.old_pos - 1]);
+    });
+    $(new_rows).appendTo($('#row_table_id'));
+    ns.renumber_positions();
+  };
+
+  ns.get_insert_before_item_id = function(wanted_pos) {
+    if (wanted_pos === '') return;
+
+    var insert_before_item_id;
+    // selection by data does not seem to work if data is changed at runtime
+    // var elt = $('.row_entry [data-position="' + wanted_pos + '"]');
+    $('.row_entry').each(function(idx, elt) {
+      if ($(elt).data("position") == wanted_pos) {
+        insert_before_item_id = $(elt).find('[name="orderitem_ids[+]"]').val();
+        return false;
+      }
+    });
+
+    return insert_before_item_id;
+  };
+
+  ns.add_item = function() {
+    if ($('#add_item_parts_id').val() === '') return;
+    if (!ns.check_cv()) return;
+
+    $('#row_table_id thead a img').remove();
+
+    var insert_before_item_id = ns.get_insert_before_item_id($('#add_item_position').val());
+
+    var data = $('#order_form').serializeArray();
+    data.push({ name: 'action', value: 'DeliveryOrder/add_item' },
+              { name: 'insert_before_item_id', value: insert_before_item_id });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.open_multi_items_dialog = function() {
+    if (!ns.check_cv()) return;
+
+    var pp = $("#add_item_parts_id").data("part_picker");
+    pp.o.multiple=1;
+    pp.open_dialog();
+  };
+
+  ns.add_multi_items = function(data) {
+    var insert_before_item_id = ns.get_insert_before_item_id($('#multi_items_position').val());
+    data = data.concat($('#order_form').serializeArray());
+    data.push({ name: 'action', value: 'DeliveryOrder/add_multi_items' },
+              { name: 'insert_before_item_id', value: insert_before_item_id });
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.delete_order_item_row = function(clicked) {
+    var row = $(clicked).parents("tbody").first();
+    $(row).remove();
+
+    ns.renumber_positions();
+  };
+
+  ns.row_table_scroll_down = function() {
+    $('#row_table_scroll_id').scrollTop($('#row_table_scroll_id')[0].scrollHeight);
+  };
+
+  ns.show_longdescription_dialog = function(clicked) {
+    var row                 = $(clicked).parents("tbody").first();
+    var position            = $(row).find('[name="position"]').html();
+    var partnumber          = $(row).find('[name="partnumber"]').html();
+    var description_elt     = $(row).find('[name="order.orderitems[].description"]');
+    var longdescription_elt = $(row).find('[name="order.orderitems[].longdescription"]');
+
+    var params = {
+      runningnumber:           position,
+      partnumber:              partnumber,
+      description:             description_elt.val(),
+      default_longdescription: longdescription_elt.val(),
+      set_function:            function(val) {
+        longdescription_elt.val(val);
+      }
+    };
+
+    kivi.SalesPurchase.edit_longdescription_with_params(params);
+  };
+
+  ns.price_chooser_item_row = function(clicked) {
+    if (!ns.check_cv()) return;
+    var row         = $(clicked).parents("tbody").first();
+    var item_id_dom = $(row).find('[name="orderitem_ids[+]"]');
+
+    var data = $('#order_form').serializeArray();
+    data.push({ name: 'action',  value: 'DeliveryOrder/price_popup' },
+              { name: 'item_id', value: item_id_dom.val()   });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.show_vc_details_dialog = function() {
+    if (!ns.check_cv()) return;
+    var vc;
+    var vc_id;
+    var title;
+    if ($('#order_customer_id').val()) {
+      vc    = 'customer';
+      vc_id = $('#order_customer_id').val();
+      title = kivi.t8('Customer details');
+    } else {
+      vc    = 'vendor';
+      vc_id = $('#order_vendor_id').val();
+      title = kivi.t8('Vendor details');
+    }
+
+    kivi.popup_dialog({
+      url:    'controller.pl',
+      data:   { action: 'DeliveryOrder/show_customer_vendor_details_dialog',
+                type  : $('#type').val(),
+                vc    : vc,
+                vc_id : vc_id
+              },
+      id:     'jq_customer_vendor_details_dialog',
+      dialog: {
+        title:  title,
+        width:  800,
+        height: 650
+      }
+    });
+    return true;
+  };
+
+  ns.update_row_from_master_data = function(clicked) {
+    var row = $(clicked).parents("tbody").first();
+    var item_id_dom = $(row).find('[name="orderitem_ids[+]"]');
+
+    var data = $('#order_form').serializeArray();
+    data.push({ name: 'action', value: 'DeliveryOrder/update_row_from_master_data' });
+    data.push({ name: 'item_ids[]', value: item_id_dom.val() });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.update_all_rows_from_master_data = function() {
+    var item_ids = $.map($('.row_entry'), function(elt) {
+      var item_id = $(elt).find('[name="orderitem_ids[+]"]').val();
+      return { name: 'item_ids[]', value: item_id };
+    });
+
+    if (item_ids.length == 0) {
+      return;
+    }
+
+    var data = $('#order_form').serializeArray();
+    data.push({ name: 'action', value: 'DeliveryOrder/update_row_from_master_data' });
+    data = data.concat(item_ids);
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.show_calculate_qty_dialog = function(clicked) {
+    var row        = $(clicked).parents("tbody").first();
+    var input_id   = $(row).find('[name="order.orderitems[].qty_as_number"]').attr('id');
+    var formula_id = $(row).find('[name="formula[+]"]').attr('id');
+
+    calculate_qty_selection_dialog("", input_id, "", formula_id);
+    return true;
+  };
+
+  ns.edit_custom_shipto = function() {
+    if (!ns.check_cv()) return;
+
+    kivi.SalesPurchase.edit_custom_shipto();
+  };
+
+  ns.purchase_order_check_for_direct_delivery = function() {
+    if ($('#type').val() != 'sales_order') {
+      kivi.submit_form_with_action($('#order_form'), 'DeliveryOrder/purchase_order');
+    }
+
+    var empty = true;
+    var shipto;
+    if ($('#order_shipto_id').val() !== '') {
+      empty = false;
+      shipto = $('#order_shipto_id option:selected').text();
+    } else {
+      $('#shipto_inputs [id^="shipto"]').each(function(idx, elt) {
+        if (!empty)                                     return true;
+        if (/^shipto_to_copy/.test($(elt).prop('id')))  return true;
+        if (/^shiptocp_gender/.test($(elt).prop('id'))) return true;
+        if (/^shiptocvar_/.test($(elt).prop('id')))     return true;
+        if ($(elt).val() !== '') {
+          empty = false;
+          return false;
+        }
+      });
+      var shipto_elements = [];
+      $([$('#shiptoname').val(), $('#shiptostreet').val(), $('#shiptozipcode').val(), $('#shiptocity').val()]).each(function(idx, elt) {
+        if (elt !== '') shipto_elements.push(elt);
+      });
+      shipto = shipto_elements.join('; ');
+    }
+
+    var use_it = false;
+    if (!empty) {
+      ns.direct_delivery_dialog(shipto);
+    } else {
+      kivi.submit_form_with_action($('#order_form'), 'DeliveryOrder/purchase_order');
+    }
+  };
+
+  ns.direct_delivery_callback = function(accepted) {
+    $('#direct-delivery-dialog').dialog('close');
+
+    if (accepted) {
+      $('<input type="hidden" name="use_shipto">').appendTo('#order_form').val('1');
+    }
+
+    kivi.submit_form_with_action($('#order_form'), 'DeliveryOrder/purchase_order');
+  };
+
+  ns.direct_delivery_dialog = function(shipto) {
+    $('#direct-delivery-dialog').remove();
+
+    var text1 = kivi.t8('You have entered or selected the following shipping address for this customer:');
+    var text2 = kivi.t8('Do you want to carry this shipping address over to the new purchase order so that the vendor can deliver the goods directly to your customer?');
+    var html  = '<div id="direct-delivery-dialog"><p>' + text1 + '</p><p>' + shipto + '</p><p>' + text2 + '</p>';
+    html      = html + '<hr><p>';
+    html      = html + '<input type="button" value="' + kivi.t8('Yes') + '" size="30" onclick="kivi.DeliveryOrder.direct_delivery_callback(true)">';
+    html      = html + '&nbsp;';
+    html      = html + '<input type="button" value="' + kivi.t8('No')  + '" size="30" onclick="kivi.DeliveryOrder.direct_delivery_callback(false)">';
+    html      = html + '</p></div>';
+    $(html).hide().appendTo('#order_form');
+
+    kivi.popup_dialog({id: 'direct-delivery-dialog',
+                       dialog: {title:  kivi.t8('Carry over shipping address'),
+                                height: 300,
+                                width:  500 }});
+  };
+
+  ns.follow_up_window = function() {
+    var id   = $('#id').val();
+    var type = $('#type').val();
+
+    var number_info = '';
+    if ($('#type').val() == 'sales_order' || $('#type').val() == 'purchase_order') {
+      number_info = $('#order_ordnumber').val();
+    } else if ($('#type').val() == 'sales_quotation' || $('#type').val() == 'request_quotation') {
+      number_info = $('#order_quonumber').val();
+    }
+
+    var name_info = '';
+    if ($('#type').val() == 'sales_order' || $('#type').val() == 'sales_quotation') {
+      name_info = $('#order_customer_id_name').val();
+    } else if ($('#type').val() == 'purchase_order' || $('#type').val() == 'request_quotation') {
+      name_info = $('#order_vendor_id_name').val();
+    }
+
+    var info = '';
+    if (number_info !== '') { info += ' (' + number_info + ')' }
+    if (name_info   !== '') { info += ' (' + name_info + ')' }
+
+    if (!$('#follow_up_rowcount').lenght) {
+      $('<input type="hidden" name="follow_up_rowcount"        id="follow_up_rowcount">').appendTo('#order_form');
+      $('<input type="hidden" name="follow_up_trans_id_1"      id="follow_up_trans_id_1">').appendTo('#order_form');
+      $('<input type="hidden" name="follow_up_trans_type_1"    id="follow_up_trans_type_1">').appendTo('#order_form');
+      $('<input type="hidden" name="follow_up_trans_info_1"    id="follow_up_trans_info_1">').appendTo('#order_form');
+      $('<input type="hidden" name="follow_up_trans_subject_1" id="follow_up_trans_subject_1">').appendTo('#order_form');
+    }
+    $('#follow_up_rowcount').val(1);
+    $('#follow_up_trans_id_1').val(id);
+    $('#follow_up_trans_type_1').val(type);
+    $('#follow_up_trans_info_1').val(info);
+    $('#follow_up_trans_subject_1').val($('#order_transaction_description').val());
+
+    follow_up_window();
+  };
+
+  ns.create_part = function() {
+    var data = $('#order_form').serializeArray();
+    data.push({ name: 'action', value: 'DeliveryOrder/create_part' });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+});
+
+$(function() {
+  $('#order_customer_id').change(kivi.DeliveryOrder.reload_cv_dependent_selections);
+  $('#order_vendor_id').change(kivi.DeliveryOrder.reload_cv_dependent_selections);
+
+  $('#order_currency_id').change(kivi.DeliveryOrder.update_exchangerate);
+  $('#order_transdate_as_date').change(kivi.DeliveryOrder.update_exchangerate);
+  $('#order_exchangerate_as_null_number').change(kivi.DeliveryOrder.exchangerate_changed);
+
+  $('#add_item_parts_id').on('set_item:PartPicker', function(e,o) { $('#add_item_description').val(o.description) });
+  $('#add_item_parts_id').on('set_item:PartPicker', function(e,o) { $('#add_item_unit').val(o.unit) });
+
+  $('.add_item_input').keydown(function(event) {
+    if (event.keyCode == 13) {
+      event.preventDefault();
+      kivi.DeliveryOrder.add_item();
+      return false;
+    }
+  });
+
+  kivi.DeliveryOrder.init_row_handlers();
+
+  $('#row_table_id').on('sortstop', function(event, ui) {
+    $('#row_table_id thead a img').remove();
+    kivi.DeliveryOrder.renumber_positions();
+  });
+
+  $('#expand_all').on('click', function(event) {
+    event.preventDefault();
+    if ($('#expand_all').data('expanded') == 1) {
+      $('#expand_all').data('expanded', 0);
+      $('#expand_all').attr('src', 'image/expand.svg');
+      $('#expand_all').attr('alt', kivi.t8('Show all details'));
+      $('#expand_all').attr('title', kivi.t8('Show all details'));
+      $('.row_entry').each(function(idx, elt) {
+        kivi.DeliveryOrder.hide_second_row(elt);
+      });
+    } else {
+      $('#expand_all').data('expanded', 1);
+      $('#expand_all').attr('src', "image/collapse.svg");
+      $('#expand_all').attr('alt', kivi.t8('Hide all details'));
+      $('#expand_all').attr('title', kivi.t8('Hide all details'));
+      kivi.DeliveryOrder.load_all_second_rows();
+      $('.row_entry').each(function(idx, elt) {
+        kivi.DeliveryOrder.show_second_row(elt);
+      });
+    }
+    return false;
+  });
+
+  $('.reformat_number_as_null_number').change(kivi.DeliveryOrder.reformat_number_as_null_number);
+
+});
index 7fb2501..31a56a4 100644 (file)
@@ -699,6 +699,26 @@ namespace("kivi", function(ns) {
       return kivi._shell_escape(elt);
     }).join(' ');
   };
+
+  ns.serialize = function(source, target = [], prefix, in_array = false) {
+    let arr_prefix = first => in_array ? (first ? "[+]" : "[]") : "";
+
+    if (Array.isArray(source) ) {
+      source.forEach(( val, i ) => {
+        ns.serialize(val, target, prefix + arr_prefix(i == 0), true);
+      });
+    } else if (typeof source === "object") {
+      let first = true;
+      for (let key in source) {
+        ns.serialize(source[key], target, (prefix !== undefined ? prefix + arr_prefix(first) + "." : "") + key);
+        first = false;
+      }
+    } else {
+      target.push({ name: prefix + arr_prefix(false), value: source });
+    }
+
+    return target;
+  };
 });
 
 kivi = namespace('kivi');
index 937c20e..859fe0e 100644 (file)
@@ -178,6 +178,7 @@ namespace("kivi").setupLocale({
 "Today":"heute",
 "Toggle marker":"Markierung umschalten",
 "Transaction description":"Vorgangsbezeichnung",
+"Transfer stock":"Lagerbewegungen",
 "Tue":"Di",
 "Tuesday":"Dienstag",
 "Update":"Erneuern",
index 3bd1203..09f4476 100755 (executable)
@@ -190,6 +190,7 @@ $self->{texts} = {
   'Add Purchase Order'          => 'Lieferantenauftrag erfassen',
   'Add Quotation'               => 'Angebot erfassen',
   'Add RFQ'                     => 'Preisanfrage erfassen',
+  'Add RMA Delivery Order'      => 'Retouren-Lieferschein erfassen',
   'Add Request for Quotation'   => 'Anfrage erfassen',
   'Add Requirement Spec'        => 'Pflichtenheft erfassen',
   'Add Requirement Spec Template' => 'Pflichtenheftvorlage erfassen',
@@ -198,6 +199,7 @@ $self->{texts} = {
   'Add Sales Order'             => 'Auftrag erfassen',
   'Add Service'                 => 'Dienstleistung erfassen',
   'Add Storno Credit Note'      => 'Gutschrift Storno hinzufügen',
+  'Add Supplier Delivery Order' => 'Beistell-Lieferschein erfassen',
   'Add Transaction'             => 'Dialogbuchen',
   'Add User'                    => 'Neuer Benutzer',
   'Add User Group'              => 'Neue Benutzergruppe',
@@ -1058,8 +1060,11 @@ $self->{texts} = {
   'Delivery Order Date'         => 'Lieferscheindatum',
   'Delivery Order Date missing!' => 'Lieferscheindatum fehlt!',
   'Delivery Order Number'       => 'Lieferscheinnummer',
+  'Delivery Order Type'         => 'Lieferschein Typ',
   'Delivery Order created'      => 'Lieferschein erstellt',
   'Delivery Order deleted!'     => 'Lieferschein gelöscht!',
+  'Delivery Order has been deleted' => 'Lieferschein wurde gelöscht',
+  'Delivery Order has been saved' => 'Lieferschein wurde gespeichert',
   'Delivery Order(s) for full qty created' => 'Lieferschein(e) mit kompletter Menge erstellt',
   'Delivery Orders'             => 'Lieferscheine',
   'Delivery Plan'               => 'Lieferplan',
@@ -1148,6 +1153,7 @@ $self->{texts} = {
   'Do you really want to mark the selected entries as booked?' => 'Möchten Sie die ausgewählten Einträge wirklich als gebucht markieren?',
   'Do you really want to print?' => 'Wollen Sie wirklich drucken?',
   'Do you really want to revert to this version?' => 'Möchten Sie wirklich auf diese Version zurücksetzen?',
+  'Do you really want to transfer the stock and set this order to delivered?' => 'Wollen Sie wirklich alle Lagerbewegungen durchführen?',
   'Do you really want to undo the selected SEPA exports? You have to reassign the export again.' => 'Möchten Sie wirklich die ausgewählten SEPA-Exports rückgängig machen? Der Export muss anschließend neu erzeugt werden.',
   'Do you really want to unimport the selected documents?' => 'Möchten Sie wirklich diese Dateien an die Quelle zurückgeben?',
   'Do you want to <b>limit</b> your search?' => 'Möchten Sie Ihre Suche <b>spezialisieren</b>?',
@@ -1261,6 +1267,7 @@ $self->{texts} = {
   'Edit Purchase Delivery Order' => 'Lieferschein (Einkauf) bearbeiten',
   'Edit Purchase Order'         => 'Lieferantenauftrag bearbeiten',
   'Edit Quotation'              => 'Angebot bearbeiten',
+  'Edit RMA Delivery Order'     => 'Retouren-Lieferschein bearbeiten',
   'Edit Request for Quotation'  => 'Anfrage bearbeiten',
   'Edit SEPA strings'           => 'Begriffe bei SEPA-Überweisungen bearbeiten',
   'Edit Sales Delivery Order'   => 'Lieferschein (Verkauf) bearbeiten',
@@ -1270,6 +1277,7 @@ $self->{texts} = {
   'Edit Storno Credit Note'     => 'Storno Gutschrift bearbeiten',
   'Edit Storno Invoice'         => 'Stornorechnung bearbeiten',
   'Edit Storno Invoice for Advance Payment' => 'Storno-Anzahlungsrechnung bearbeiten',
+  'Edit Supplier Delivery Order' => 'Beistell-Lieferschein bearbeiten',
   'Edit User'                   => 'Benutzerdaten bearbeiten',
   'Edit User Group'             => 'Benutzergruppe bearbeiten',
   'Edit Vendor'                 => 'Lieferant editieren',
@@ -1898,7 +1906,6 @@ $self->{texts} = {
   'Invoices with payments cannot be canceled.' => 'Rechnungen mit Zahlungen können nicht storniert werden.',
   'Invoices, Credit Notes & AR Transactions' => 'Rechnungen, Gutschriften & Debitorenbuchungen',
   'Is Searchable'               => 'Durchsuchbar',
-  'Is sales'                    => 'Verkauf',
   'Is this a summary account to record' => 'Sammelkonto für',
   'It can be changed later but must be unique within the installation.' => 'Er ist nachträglich änderbar, muss aber im System eindeutig sein.',
   'It is not allowed that a summary account occurs in a drop-down menu!' => 'Ein Sammelkonto darf nicht in Aufklappmenüs aufgenommen werden!',
@@ -2139,6 +2146,7 @@ $self->{texts} = {
   'Name of the goal/source (if field names remote_name and remote_name_1 exist they will be combined into field "remote_name")' => 'Name des Ziel- oder Quellkontos (wenn die Spalten remote_name und remote_name_1 existieren werden diese zu Feld "remote_name" zusammengefügt)',
   'Need a image title'          => 'Benötige einen Titel für das Bild',
   'Need a valid Shop Part for updating Part' => 'Benötige eine gültiges Shop Part Objekt, um den Artikel zu aktualisieren.',
+  'Need a workflow for Supplier Delivery Order' => 'Benötige zwingend einen Workflow-Vorgänger für den Beistell-Lieferschein',
   'Need at least one original position for the workflow Order to Delivery Order!' => 'Benötige mindestens eine Position die aus dem Auftrag übernommen wurde, ansonsten ist der Workflow inkosistent.',
   'Need charge number!'         => 'Benötige Chargennummer!',
   'Negative reductions are possible to model price increases.' => 'Negative Abschläge sind möglich um Aufschläge zu modellieren.',
@@ -2257,6 +2265,7 @@ $self->{texts} = {
   'No shipto city'              => 'Die Stadt für die Lieferadresse fehlt',
   'No shipto selected to delete' => 'Keine Lieferadresse zum Löschen ausgewählt',
   'No start date given, setting to #1' => 'Kein Startdatum gegeben, setze Startdatum auf #1',
+  'No stock to transfer'        => 'Keine Lagerbewegungen vorhanden',
   'No such job #1 in the database.' => 'Hintergrund-Job #1 existiert nicht mehr.',
   'No summary account'          => 'Kein Sammelkonto',
   'No superuser credentials were entered.' => 'Es wurden keine Super-Benutzer-Anmeldedaten eingegeben.',
@@ -2450,7 +2459,7 @@ $self->{texts} = {
   'Part Notes'                  => 'Bemerkungen',
   'Part Number'                 => 'Artikelnummer',
   'Part Number missing!'        => 'Artikelnummer fehlt!',
-  'Part Test'                   => '',
+  'Part Test'                   => 'Artikel-Test',
   'Part Type'                   => 'Artikel-Typ',
   'Part Unit'                   => 'Einheit',
   'Part classifications'        => 'Artikel-Klassifizierungen',
@@ -2785,6 +2794,9 @@ $self->{texts} = {
   'RFQ Date'                    => 'Anfragedatum',
   'RFQ Number'                  => 'Anfragenummer',
   'RFQs'                        => 'Preisanfragen',
+  'RMA Delivery Order'          => 'Retouren-Lieferschein',
+  'RMA Delivery Orders'         => 'Retouren-Lieferscheine',
+  'RMA delivery order'          => 'Retouren-Lieferschein',
   'ROP'                         => 'Mindestlagerbestand',
   'Ranges of numbers'           => 'Nummernkreise',
   'Re-numbering all sections and function blocks in the order they are currently shown cannot be undone.' => 'Das Neu-Nummerieren aller Abschnitte und Funktionsblöcke kann nicht rückgängig gemacht werden.',
@@ -3013,6 +3025,7 @@ $self->{texts} = {
   'Save and Quotation'          => 'Speichern und Angebot',
   'Save and RFQ'                => 'Speichern und Lieferantenanfrage',
   'Save and Sales Order'        => 'Speichern und Kundenauftrag',
+  'Save and Supplier Delivery Order' => 'Speichern und Beistelllieferschein',
   'Save and close'              => 'Speichern und schließen',
   'Save and execute'            => 'Speichern und ausführen',
   'Save and keep open'          => 'Speichern und geöffnet lassen',
@@ -3302,6 +3315,7 @@ $self->{texts} = {
   'Stock Qty for Date'          => 'Lagerbestand am',
   'Stock for part #1'           => 'Bestand für Artikel #1',
   'Stock levels'                => 'Lagerbestände',
+  'Stock transfered'            => 'Lagerbewegungen ausgeführt',
   'Stock value'                 => 'Bestandswert',
   'StockInfo'                   => 'Lagerinfo',
   'Stocked Qty'                 => 'Lagermenge',
@@ -3351,6 +3365,11 @@ $self->{texts} = {
   'Sun'                         => 'So',
   'Sunday'                      => 'Sonntag',
   'Superuser name'              => 'Datenbankadministrator',
+  'Supplier Delivery Order'     => 'Beistell-Lieferschein',
+  'Supplier Delivery Order has been deleted' => 'Beistell-Lieferschein wurde gelöscht',
+  'Supplier Delivery Order has been saved' => 'Beistell-Lieferschein wurde gespeichert',
+  'Supplier Delivery Orders'    => 'Beistell-Lieferscheine',
+  'Supplier delivery order'     => 'Beistell-Lieferschein',
   'Supplies'                    => 'Lieferungen',
   'Surname'                     => 'Nachname',
   'Switzerland'                 => 'Schweiz',
@@ -3653,6 +3672,7 @@ $self->{texts} = {
   'The partnumber is missing.'  => 'Die Artikelnummer fehlt.',
   'The parts for this delivery order have already been transferred in.' => 'Die Artikel dieses Lieferscheins wurden bereits eingelagert.',
   'The parts for this delivery order have already been transferred out.' => 'Die Artikel dieses Lieferscheins wurden bereits ausgelagert.',
+  'The parts for this order have already been transferred' => 'Die Artikel in diesem Lieferschein wurden schon umgelagert',
   'The parts have been removed.' => 'Die Waren wurden aus dem Lager entnommen.',
   'The parts have been transferred.' => 'Die Waren wurden umgelagert.',
   'The partsgroup is missing.'  => 'Die Warengruppe fehlt.',
@@ -3996,6 +4016,7 @@ $self->{texts} = {
   'Transfer out via default'    => 'Auslagern über Standard-Lagerplatz',
   'Transfer qty'                => 'Umlagermenge',
   'Transfer services via default' => 'Falls Ein- /Auslagern über Standardlagerplatz aktiviert ist, auch die Dienstleistungen standardmässig Ein- und Auslagern',
+  'Transfer stock'              => 'Lagerbewegungen',
   'Transfer successful'         => 'Lagervorgang erfolgreich',
   'Transfer undone.'            => 'Zurücklagerung erfolgreich',
   'Transferred'                 => 'Übernommen',
@@ -4561,6 +4582,7 @@ $self->{texts} = {
   'return_material'             => 'Materialrückgabe',
   'revert deleted'              => 'löschen rückgängig',
   'rfq_list'                    => 'anfragenliste',
+  'rma_delivery_order_list'     => 'lieferscheinliste_rma',
   'running'                     => 'läuft',
   'sales tax identification number' => 'USt-IdNr.',
   'sales_delivery_order_list'   => 'lieferscheinliste_verkauf',
@@ -4590,6 +4612,7 @@ $self->{texts} = {
   'stocktaking'                 => 'Inventur',
   'succeeded'                   => 'erfolgreich',
   'sum'                         => 'Summe',
+  'supplier_delivery_order_list' => 'lieferscheinliste_beistell',
   'tax_chartaccno'              => 'Automatikkonto',
   'tax_percent'                 => 'Prozentsatz',
   'tax_rate'                    => 'Prozent',
index 52d7b07..985b2cc 100644 (file)
   params:
     action: search
     type: purchase_delivery_order
+- parent: ap_reports
+  id: ap_reports_supplier_delivery_orders
+  name: Supplier Delivery Orders
+  order: 350
+  access: purchase_delivery_order_edit
+  module: do.pl
+  params:
+    action: search
+    type: supplier_delivery_order
 - parent: ap_reports
   id: ap_reports_vendor_invoices_ap_transactions
   name: Vendor Invoices & AP Transactions
diff --git a/sql/Pg-upgrade2/deliveryorder_transnumbers.sql b/sql/Pg-upgrade2/deliveryorder_transnumbers.sql
new file mode 100644 (file)
index 0000000..3e03150
--- /dev/null
@@ -0,0 +1,11 @@
+-- @tag: deliveryorder_transnumbers
+-- @description: Nummernkreise für neue lieferscheintypen
+-- @depends: release_3_5_8
+
+ALTER TABLE defaults ADD COLUMN sudonumber TEXT;
+ALTER TABLE defaults ADD COLUMN rdonumber TEXT;
+
+UPDATE defaults SET
+  sudonumber = '0',
+  rdonumber = '0';
+
diff --git a/sql/Pg-upgrade2/deliveryorder_type.sql b/sql/Pg-upgrade2/deliveryorder_type.sql
new file mode 100644 (file)
index 0000000..8e4a0f4
--- /dev/null
@@ -0,0 +1,12 @@
+-- @tag: deliveryorder_type
+-- @description: Persistente Typen in Lieferscheinen
+-- @depends: release_3_5_8
+
+ALTER TABLE delivery_orders ADD COLUMN order_type TEXT;
+
+UPDATE delivery_orders SET order_type = 'sales_delivery_order' WHERE customer_id IS NOT NULL;
+UPDATE delivery_orders SET order_type = 'purchase_delivery_order' WHERE vendor_id IS NOT NULL;
+
+ALTER TABLE delivery_orders ALTER COLUMN order_type SET NOT NULL;
+
+
diff --git a/t/db/delivery_order.t b/t/db/delivery_order.t
new file mode 100644 (file)
index 0000000..372cc87
--- /dev/null
@@ -0,0 +1,49 @@
+use Test::More;
+
+use strict;
+
+use lib 't';
+use utf8;
+
+use Carp;
+use Data::Dumper;
+use Support::TestSetup;
+use Test::Exception;
+
+use SL::DB::Order;
+use SL::DB::Customer;
+use SL::DB::Department;
+use SL::DB::Currency;
+use SL::DB::PaymentTerm;
+use SL::DB::DeliveryTerm;
+use SL::DB::Employee;
+use SL::DB::Part;
+use SL::DB::Unit;
+use SL::DB::DeliveryOrder;
+use SL::DB::DeliveryOrder::TypeData qw(:types);
+
+use SL::Dev::ALL qw(:ALL);
+
+Support::TestSetup::login();
+
+
+#######
+
+my $order1 = SL::Dev::Record::create_purchase_order(
+  save                    => 1,
+  taxincluded             => 0,
+);
+
+my $delivery_order = SL::DB::DeliveryOrder->new_from($order1);
+
+is $delivery_order->type, PURCHASE_DELIVERY_ORDER_TYPE, "new_from purchase order gives purchase delivery order";
+is scalar @{ $delivery_order->items }, 2, "purchase delivery order keeps items";
+is $delivery_order->vendor_id, $order1->vendor_id, "purchase delivery order keeps vendor";
+
+my $supplier_delivery_order = SL::DB::DeliveryOrder->new_from($order1, type => SUPPLIER_DELIVERY_ORDER_TYPE);
+
+is $supplier_delivery_order->type, SUPPLIER_DELIVERY_ORDER_TYPE, "new_from purchase order with given type gives supplier delivery order";
+is scalar @{ $supplier_delivery_order->items }, 0, "supplier delivery order ignores items";
+is $supplier_delivery_order->vendor_id, $order1->vendor_id, "supplier delivery order keeps vendor";
+
+done_testing();
index 0c46af9..2c7ea4d 100644 (file)
@@ -19,6 +19,7 @@ use SL::DB::Employee;
 use SL::DB::Invoice;
 use SL::DB::Order;
 use SL::DB::DeliveryOrder;
+use SL::DB::DeliveryOrder::TypeData qw(:types);
 use SL::DB::Part;
 use SL::DB::Unit;
 use SL::DB::TaxZone;
@@ -86,6 +87,7 @@ sub new_delivery_order {
     employee_id => $employee->id,
     salesman_id => $employee->id,
     taxzone_id  => $taxzone->id,
+    order_type => SALES_DELIVERY_ORDER_TYPE,
     %params,
   )->save;
 }
index 75276e4..47eafa1 100644 (file)
 
 % einkaufslieferschein (purchase_delivery_order)
 \newcommand{\einkaufslieferschein} {Eingangslieferschein}
+\newcommand{\beistelllieferschein} {Beistell-Lieferschein}
 
 % Brief/letter
 \newcommand{\ihrzeichen}{Ihr Zeichen}
index a6322df..f757c78 100644 (file)
 
 % einkaufslieferschein (purchase_delivery_order)
 \newcommand{\einkaufslieferschein} {Purchase delivery order}
+\newcommand{\beistelllieferschein} {Supplier Delivery Order}
 
 % Brief/letter
 \newcommand{\ihrzeichen}{Your reference}
index 123eef8..ddadec8 100644 (file)
 % settings: Einstellungen, Logo, Briefpapier, Kopfzeile, Fusszeile
 \input{insettings.tex}
 
+<%if template_meta.formname == "supplier_delivery_order"%>
+  \renewcommand{\einkaufslieferschein} {\beistelllieferschein}
+<%end%>
+
 
 % laufende Kopfzeile:
 \ourhead{}{}{\einkaufslieferschein}{<%donumber%>}{<%dodate%>}
diff --git a/templates/print/marei/supplier_delivery_order.tex b/templates/print/marei/supplier_delivery_order.tex
new file mode 120000 (symlink)
index 0000000..7d185e6
--- /dev/null
@@ -0,0 +1 @@
+purchase_delivery_order.tex
\ No newline at end of file
diff --git a/templates/webpages/delivery_order/form.html b/templates/webpages/delivery_order/form.html
new file mode 100644 (file)
index 0000000..d7facc2
--- /dev/null
@@ -0,0 +1,57 @@
+[%- USE T8 %]
+[%- USE LxERP %]
+[%- USE L %]
+[%- USE HTML %]
+<h1>[% FORM.title %] <span id='nr_in_title'>[%- SELF.order.number -%]</span></h1>
+
+<div id="print_options" style="display:none">
+  <form method="post" action="controller.pl" id="print_options_form">
+    [% SELF.print_options %]
+    <br>
+    [% L.button_tag('kivi.DeliveryOrder.print()', LxERP.t8('Print')) %]
+    <a href="#" onclick="$('#print_options').dialog('close');">[% LxERP.t8("Cancel") %]</a>
+  </form>
+</div>
+
+<div id="shipto_dialog" class="hidden"></div>
+
+<form method="post" action="controller.pl" id="order_form">
+  [% L.hidden_tag('callback',             FORM.callback) %]
+  [% L.hidden_tag('type',                 FORM.type) %]
+  [% L.hidden_tag('id',                   SELF.order.id) %]
+  [% L.hidden_tag('converted_from_oe_id', SELF.converted_from_oe_id) %]
+
+  [%- INCLUDE 'common/flash.html' %]
+
+  <div class="tabwidget" id="order_tabs">
+    <ul>
+      <li><a href="#ui-tabs-basic-data">[% 'Basic Data' | $T8 %]</a></li>
+[%- IF INSTANCE_CONF.get_webdav %]
+      <li><a href="#ui-tabs-webdav">[% 'WebDAV' | $T8 %]</a></li>
+[%- END %]
+[%- IF SELF.order.id AND INSTANCE_CONF.get_doc_storage %]
+      <li><a href="controller.pl?action=File/list&file_type=document&object_type=[% HTML.escape(FORM.type) %]&object_id=[% HTML.url(SELF.order.id) %]">[% 'Documents' | $T8 %]</a></li>
+      <li><a href="controller.pl?action=File/list&file_type=attachment&object_type=[% HTML.escape(FORM.type) %]&object_id=[% HTML.url(SELF.order.id) %]">[% 'Attachments' | $T8 %]</a></li>
+[%- END %]
+[%- IF SELF.order.id %]
+      <li><a href="controller.pl?action=RecordLinks/ajax_list&object_model=DeliveryOrder&object_id=[% HTML.url(SELF.order.id) %]">[% 'Linked Records' | $T8 %]</a></li>
+[%- END %]
+    </ul>
+
+    [% PROCESS "delivery_order/tabs/basic_data.html" %]
+    [% PROCESS 'webdav/_list.html' %]
+    <div id="ui-tabs-1">
+      [%- LxERP.t8("Loading...") %]
+    </div>
+
+    <div id="shipto_inputs" class="hidden">
+      [%- PROCESS 'common/_ship_to_dialog.html'
+        vc_obj=SELF.order.customervendor
+        cs_obj=SELF.order.custom_shipto
+        cvars=SELF.order.custom_shipto.cvars_by_config
+        id_selector='#order_shipto_id' %]
+    </div>
+
+  </div>
+
+</form>
diff --git a/templates/webpages/delivery_order/stock_dialog.html b/templates/webpages/delivery_order/stock_dialog.html
new file mode 100644 (file)
index 0000000..f4935cf
--- /dev/null
@@ -0,0 +1,100 @@
+[%- USE T8 %]
+[%- USE HTML %]
+[%- USE LxERP %]
+[%- USE L %]
+ [%- IF delivered %]
+ [%- SET RO = ' readonly' %]
+ [%- END %]
+
+ <table>
+  <tr>
+   <td>[% 'Part Number' | $T8 %]</td>
+   <td>[% part.partnumber | html %]</td>
+  </tr>
+  <tr>
+   <td>[% 'Description' | $T8 %]</td>
+   <td>[% part.description | html %]</td>
+  </tr>
+  <tr>
+   <td>[% 'Qty according to delivery order' | $T8 %]</td>
+   <td>[% LxERP.format_amount(do_qty) %] [% do_unit | html %]</td>
+  </tr>
+ </table>
+
+  [%- UNLESS WHCONTENTS.size %]
+  <p>[% 'There are no items in stock.' | $T8 %]</p>
+  [%- ELSE %]
+
+  [% L.hidden_tag("in_out", in_out) %]
+  [% L.hidden_tag("parts_id", parts_id) %]
+  [% L.hidden_tag("do_qty", do_qty) %]
+  [% L.hidden_tag("do_unit", do_unit) %]
+  [% L.hidden_tag("row", row, class="data-row") %]
+  [% L.hidden_tag("item_id", item_id) %]
+
+  <p>
+   <table id="stock-in-out-table">
+    <tr class="listheading">
+     <th>&nbsp;</th>
+     <th>[% 'Warehouse' | $T8 %]</th>
+     <th>[% 'Bin' | $T8 %]</th>
+     <th>[% 'Charge Number' | $T8 %]</th>
+     [% IF INSTANCE_CONF.get_show_bestbefore %]
+     <th>[% 'Best Before' | $T8 %]</th>
+     [% END %]
+     [%- UNLESS delivered %]
+     <th align="right">[% 'Available qty' | $T8 %]</th>
+     [%- END %]
+     <th align="right">[% 'Qty' | $T8 %]</th>
+     <th align="right">[% 'Unit' | $T8 %]</th>
+    </tr>
+
+    [%- FOREACH row = WHCONTENTS %]
+    <tr [% IF row.stock_error %] class="error"[% ELSE %]class="listrow"[% END %]>
+     <td>[% loop.count %]</td>
+     <td>[% row.warehousedescription | html %]</td>
+     <td>[% row.bindescription | html %]</td>
+     <td>[% row.chargenumber | html %]</td>
+     [% IF INSTANCE_CONF.get_show_bestbefore %]
+     <td>[% row.bestbefore | html %]</td>
+     [% END %]
+
+     [%- IF delivered %]
+
+     <td>[% LxERP.format_amount(row.stock_qty) | html %]</td>
+     <td>
+      [% row.stock_unit | html %]
+      [% L.hidden_tag("unit", row.stock_unit, class="data-unit") %]
+     </td>
+
+     [%- ELSE %]
+
+     <td>[% row.available_qty | html %]</td>
+     <td>
+      [% L.input_tag("qty", row.stock_qty                              ? LxERP.format_amount(row.stock_qty)
+                          : (WHCONTENTS.size == 1) && (!row.stock_qty) ? LxERP.format_amount(do_qty)
+                          : "", class="numeric data-qty", size="12") %]</td>
+     <td>[% L.select_tag("unit_" _ loop.count, part.unit_obj.convertible_units, value_key="name", default=row.stock_unit, class="data-unit") %]</td>
+
+     [%- END %]
+     <td style="display:none">
+      [% L.hidden_tag("warehouse_id", row.warehouse_id, class="data-warehouse-id") %]
+      [% L.hidden_tag("bin_id", row.bin_id, class="data-bin-id") %]
+      [% L.hidden_tag("chargenumber", row.chargenumber, class="data-chargenumber") %]
+      [% L.hidden_tag("delivery_order_items_stock_id", row.delivery_order_items_stock_id, class="data-stock-id") %]
+      [% L.hidden_tag("bestbefore", row.bestbefore, class="data-bestbefore") IF INSTANCE_CONF.get_show_bestbefore %]
+     </td>
+    </tr>
+
+    [%- END %]
+   </table>
+  </p>
+
+  <hr size="3" noshade>
+
+  <p>[% L.button_tag('kivi.DeliveryOrder.save_updated_stock()', LxERP.t8('Save')) IF !delivered %]</p>
+
+  [%- END %]
+ </form>
+
+
diff --git a/templates/webpages/delivery_order/tabs/_business_info_row.html b/templates/webpages/delivery_order/tabs/_business_info_row.html
new file mode 100644 (file)
index 0000000..cdd61a6
--- /dev/null
@@ -0,0 +1,10 @@
+[%- USE T8 %][%- USE HTML %]
+
+<tr id='business_info_row' [%- IF !SELF.order.customervendor.business_id %]style='display:none'[%- END %]>
+  <th align="right">[%- IF SELF.cv == 'customer' -%]
+                      [%- 'Customer type' | $T8 -%]
+                    [%- ELSE -%]
+                      [%- 'Vendor type' | $T8 -%]
+                    [%- END -%]</th>
+  <td>[% HTML.escape(SELF.order.customervendor.business.description) %]; [% 'Trade Discount' | $T8 %] [% SELF.order.customervendor.business.discount_as_percent %] %</td>
+</tr>
diff --git a/templates/webpages/delivery_order/tabs/_item_input.html b/templates/webpages/delivery_order/tabs/_item_input.html
new file mode 100644 (file)
index 0000000..ab62bd9
--- /dev/null
@@ -0,0 +1,44 @@
+[%- USE T8 %][%- USE HTML %][%- USE LxERP %][%- USE L %][%- USE P %]
+
+ <div>
+  <table id="input_row_table_id">
+    <thead>
+      <tr class="listheading">
+        <th class="listheading" nowrap >[%- '+'            | $T8 %] </th>
+        <th class="listheading" nowrap >[%- 'position'     | $T8 %] </th>
+        <th class="listheading" nowrap >[%- 'Part'         | $T8 %] </th>
+        <th class="listheading" nowrap >[%- 'Description'  | $T8 %] </th>
+        <th class="listheading" nowrap width="5" >[%- 'Qty'          | $T8 %] </th>
+        <th></th>
+      </tr>
+    </thead>
+    <tbody>
+      <tr valign="top" class="listrow">
+        <td class="tooltipster-html" title="[%- 'Create a new part' | $T8 -%]">
+          [% SET type_options = [[ 'part', LxERP.t8('Part') ], [ 'assembly', LxERP.t8('Assembly') ]] %]
+          [%- IF INSTANCE_CONF.get_feature_experimental_assortment %]
+            [%- type_options.push([ 'assortment', LxERP.t8('Assortment')]) %]
+          [%- END %]
+          [% L.select_tag('add_item.create_part_type', type_options) %]
+          [% L.button_tag('kivi.DeliveryOrder.create_part()', LxERP.t8('+')) %]
+        </td>
+        <td>[% L.input_tag('add_item.position', '', size = 5, class="add_item_input numeric") %]</td>
+        <td>
+          [%- SET PARAM_KEY = SELF.type_data.properties('is_customer') ? 'with_customer_partnumber' : 'with_makemodel' -%]
+          [%- SET PARAM_VAL = SELF.search_cvpartnumber -%]
+          [% P.part.picker('add_item.parts_id', SELF.created_part, style='width: 300px', class="add_item_input",
+                            fat_set_item=1,
+                            multiple_pos_input=1,
+                            action={set_multi_items='kivi.DeliveryOrder.add_multi_items'},
+                            classification_id=SELF.part_picker_classification_ids.as_list.join(','),
+                            $PARAM_KEY=PARAM_VAL) %]</td>
+        <td>[% L.input_tag('add_item.description', SELF.created_part.description, class="add_item_input") %]</td>
+        <td>
+          [% L.input_tag('add_item.qty_as_number', '', size = 5, class="add_item_input numeric") %]
+          [% L.hidden_tag('add_item.unit', SELF.created_part.unit, class="add_item_input") %]
+        </td>
+        <td>[% L.button_tag('kivi.DeliveryOrder.add_item()', LxERP.t8('Add part')) %]</td>
+      </tr>
+    </tbody>
+  </table>
+ </div>
diff --git a/templates/webpages/delivery_order/tabs/_row.html b/templates/webpages/delivery_order/tabs/_row.html
new file mode 100644 (file)
index 0000000..82422ef
--- /dev/null
@@ -0,0 +1,110 @@
+[%- USE T8 %]
+[%- USE HTML %]
+[%- USE LxERP %]
+[%- USE L %]
+[%- USE P %]
+
+<tbody class="row_entry listrow" data-position="[%- HTML.escape(ITEM.position) -%]"[%- IF MYCONFIG.show_form_details -%] data-expanded="1"[%- END -%]>
+  <tr>
+    <td align="center">
+      [%- IF MYCONFIG.show_form_details %]
+        [% L.img_tag(src="image/collapse.svg",
+                     alt=LxERP.t8('Hide details'), title=LxERP.t8('Hide details'), class="expand") %]
+      [%- ELSE %]
+        [% L.img_tag(src="image/expand.svg",
+                     alt=LxERP.t8('Show details'), title=LxERP.t8('Show details'), class="expand") %]
+      [%- END %]
+      [% L.hidden_tag("orderitem_ids[+]", ID) %]
+      [% L.hidden_tag("converted_from_orderitems_ids[+]", ITEM.converted_from_orderitems_id) %]
+      [% L.hidden_tag("order.orderitems[+].id", ITEM.id, id='item_' _ ID) %]
+      [% L.hidden_tag("order.orderitems[].stock_info", ITEM.stock_info, class="data-stock-info") %]
+      [% L.hidden_tag("order.orderitems[].parts_id", ITEM.parts_id) %]
+    </td>
+    <td>
+      <div name="position" class="numeric">
+        [% HTML.escape(ITEM.position) %]
+      </div>
+    </td>
+    <td align="center">
+      <img src="image/updown.png" alt="[%- LxERP.t8('reorder item') %]" class="dragdrop">
+    </td>
+    <td align="center">
+      [%- L.button_tag("kivi.DeliveryOrder.delete_order_item_row(this)",
+                       LxERP.t8("X"),
+                       confirm=LxERP.t8("Are you sure?")) %]
+    </td>
+    [%- IF SELF.show_update_button -%]
+    <td align="center">
+      [%- L.img_tag(src="image/rotate_cw.svg",
+                    alt=LxERP.t8('Update from master data'),
+                    title= LxERP.t8('Update from master data'),
+                    onclick="if (!confirm('" _ LxERP.t8("Are you sure to update this position from master data?") _ "')) return false; kivi.DeliveryOrder.update_row_from_master_data(this);",
+                    id='update_from_master') %]
+    </td>
+    [%- END -%]
+    <td>
+      <div name="partnumber">
+        [%- P.link_tag(SELF.url_for(controller='Part', action='edit', 'part.id'=ITEM.part.id), ITEM.part.partnumber, target="_blank", title=LxERP.t8('Open in new window')) -%]
+      </div>
+    </td>
+    [%- IF SELF.search_cvpartnumber -%]
+    <td>
+      <div name="cvpartnumber">[% HTML.escape(ITEM.cvpartnumber) %]</div>
+    </td>
+    [%- END -%]
+    <td>
+      <div name="partclassification">[% ITEM.part.presenter.typeclass_abbreviation %]</div>
+    </td>
+    <td>
+      [% L.areainput_tag("order.orderitems[].description",
+                     ITEM.description,
+                     size='40',
+                     style='width: 300px') %]
+      [%- L.hidden_tag("order.orderitems[].longdescription", ITEM.longdescription) %]
+      [%- L.button_tag("kivi.DeliveryOrder.show_longdescription_dialog(this)", LxERP.t8("L")) %]
+    </td>
+    [%- IF (SELF.type == "sales_order" || SELF.type == "purchase_order") -%]
+    <td nowrap>
+      [%- L.div_tag(LxERP.format_amount(ITEM.shipped_qty, 2, 0) _ ' ' _ ITEM.unit, name="shipped_qty", class="numeric") %]
+    </td>
+    [%- END -%]
+    <td nowrap>
+      [%- L.input_tag("order.orderitems[].qty_as_number",
+                      ITEM.qty_as_number,
+                      size = 5,
+                      class="reformat_number numeric") %]
+      [%- IF ITEM.part.formel -%]
+        [%- L.button_tag("kivi.DeliveryOrder.show_calculate_qty_dialog(this)", LxERP.t8("*/")) %]
+        [%- L.hidden_tag("formula[+]", ITEM.part.formel) -%]
+      [%- END -%]
+    </td>
+    <td nowrap>
+      [%- L.select_tag("order.orderitems[].unit",
+                      ITEM.part.available_units,
+                      default = ITEM.unit,
+                      title_key = 'name',
+                      value_key = 'name',
+                      class = 'unitselect') %]
+    </td>
+
+    <td>
+      <span id="stock_[% ID %]" class="data-stock-qty">[% SELF.calculate_stock_in_out(ITEM) %] [% ITEM.unit %]</span>
+      [% P.button_tag("kivi.DeliveryOrder.open_stock_in_out_dialog(this, '" _ in_out _"')", "?") %]
+    </td>
+  </tr>
+
+  <tr [%- IF !MYCONFIG.show_form_details -%]style="display:none"[%- END -%]>
+    <td colspan="100%">
+      [%- IF MYCONFIG.show_form_details || ITEM.render_second_row %]
+        <div name="second_row" data-loaded="1">
+          [%- PROCESS delivery_order/tabs/_second_row.html ITEM=ITEM TYPE=SELF.type %]
+        </div>
+      [%- ELSE %]
+        <div name="second_row" id="second_row_[% ID %]">
+          [%- LxERP.t8("Loading...") %]
+        </div>
+      [%- END %]
+    </td>
+  </tr>
+
+</tbody>
diff --git a/templates/webpages/delivery_order/tabs/_second_row.html b/templates/webpages/delivery_order/tabs/_second_row.html
new file mode 100644 (file)
index 0000000..6b140ac
--- /dev/null
@@ -0,0 +1,45 @@
+[%- USE T8 %]
+[%- USE HTML %]
+[%- USE LxERP %]
+[%- USE L %]
+[%- USE P %]
+
+<table>
+  <tr><td colspan="100%">
+    [%- IF (TYPE == "sales_order" || TYPE == "purchase_order") %]
+      <b>[%- 'Serial No.' | $T8 %]</b>&nbsp;
+      [%- L.input_tag("order.orderitems[].serialnumber", ITEM.serialnumber, size = 15) %]&nbsp;
+    [%- END %]
+    <b>[%- 'Project' | $T8 %]</b>&nbsp;
+    [% P.project.picker("order.orderitems[].project_id", ITEM.project_id, size = 15) %]&nbsp;
+    [%- IF (TYPE == "sales_order" || TYPE == "purchase_order") %]
+      <b>[%- 'Reqdate' | $T8 %]</b>&nbsp;
+      [% L.date_tag("order.orderitems[].reqdate_as_date", ITEM.reqdate_as_date) %]&nbsp;
+    [%- END %]
+    <b>[%- 'On Hand' | $T8 %]</b>&nbsp;
+      <span[%- IF ITEM.part.onhand < ITEM.part.rop -%] class="numeric plus0"[%- END -%]>
+        [%- ITEM.part.onhand_as_number -%]&nbsp;[%- ITEM.part.unit -%]
+      </span>&nbsp;
+  </td></tr>
+
+  <tr>
+    [%- SET n = 0 %]
+    [%- FOREACH var = ITEM.cvars_by_config %]
+    [%- NEXT UNLESS (var.config.processed_flags.editable && ITEM.part.cvar_by_name(var.config.name).is_valid) %]
+    [%- SET n = n + 1 %]
+    <th>
+      [% var.config.description %]
+    </th>
+    <td>
+      [% L.hidden_tag('order.orderitems[].custom_variables[+].config_id', var.config.id) %]
+      [% L.hidden_tag('order.orderitems[].custom_variables[].id', var.id) %]
+      [% L.hidden_tag('order.orderitems[].custom_variables[].sub_module', var.sub_module) %]
+      [% INCLUDE 'common/render_cvar_input.html' var_name='order.orderitems[].custom_variables[].unparsed_value' %]
+    </td>
+    [%- IF (n % (MYCONFIG.form_cvars_nr_cols || 3)) == 0 %]
+
+  </tr><tr>
+    [%- END %]
+    [%- END %]
+  </tr>
+</table>
diff --git a/templates/webpages/delivery_order/tabs/basic_data.html b/templates/webpages/delivery_order/tabs/basic_data.html
new file mode 100644 (file)
index 0000000..f4c052b
--- /dev/null
@@ -0,0 +1,280 @@
+[%- USE T8 %]
+[%- USE HTML %]
+[%- USE LxERP %]
+[%- USE L %]
+[%- USE P %]
+
+[%- INCLUDE 'generic/set_longdescription.html' %]
+
+<div id="ui-tabs-basic-data">
+  <table width="100%">
+    <tr valign="top">
+      <td>
+        <table width="100%">
+          <tr>
+            <th align="right">[%- SELF.cv == "customer" ? LxERP.t8('Customer') : LxERP.t8('Vendor') -%]</th>
+            [% SET cv_id = SELF.cv _ '_id' %]
+            <td>
+              [% P.customer_vendor.picker("order.${SELF.cv}" _ '_id', SELF.order.$cv_id, type=SELF.cv, style='width: 300px') %]
+              [% P.button_tag("kivi.DeliveryOrder.show_vc_details_dialog()", LxERP.t8("Details (one letter abbreviation)")) %]
+            </td>
+          </tr>
+
+          <tr id='cp_row' [%- IF !SELF.order.${SELF.cv}.contacts.size %]style='display:none'[%- END %]>
+            <th align="right">[% 'Contact Person' | $T8 %]</th>
+            <td>[% L.select_tag('order.cp_id',
+                                SELF.order.${SELF.cv}.contacts,
+                                default=SELF.order.cp_id,
+                                title_key='full_name_dep',
+                                value_key='cp_id',
+                                with_empty=1,
+                                style='width: 300px') %]</td>
+          </tr>
+
+          <tr>
+            <th align="right">[% 'Shipping Address' | $T8 %]</th>
+            <td>
+              <span id='shipto_selection' [%- IF !SELF.order.${SELF.cv}.shipto.size %]style='display:none'[%- END %]>
+                [% shiptos = [ { shipto_id => "", displayable_id => LxERP.t8("No/individual shipping address") } ] ;
+                   FOREACH s = SELF.order.${SELF.cv}.shipto ;
+                     shiptos.push(s) ;
+                   END ;
+                   L.select_tag('order.shipto_id',
+                                 shiptos,
+                                 default=SELF.order.shipto_id,
+                                 title_key='displayable_id',
+                                 value_key='shipto_id',
+                                 with_empty=0,
+                                 style='width: 300px') %]
+              </span>
+              [% L.button_tag("kivi.DeliveryOrder.edit_custom_shipto()", LxERP.t8("Custom shipto")) %]
+            </td>
+          </tr>
+
+          [%- PROCESS delivery_order/tabs/_business_info_row.html SELF=SELF %]
+
+[%- IF SELF.all_languages.size %]
+          <tr>
+            <th align="right">[% 'Language' | $T8 %]</th>
+            <td>
+              [% L.select_tag('order.language_id', SELF.all_languages, default=SELF.order.language_id, title_key='description', with_empty=1, style='width:300px') %]
+            </td>
+          </tr>
+[%- END %]
+
+[%- IF SELF.all_departments.size %]
+          <tr>
+            <th align="right">[% 'Department' | $T8 %]</th>
+            <td>
+              [% L.select_tag('order.department_id', SELF.all_departments, default=SELF.order.department_id, title_key='description', with_empty=1, style='width:300px') %]
+            </td>
+          </tr>
+[%- END %]
+
+          <tr>
+            <th align="right">[% 'Shipping Point' | $T8 %]</th>
+            <td>[% L.input_tag('order.shippingpoint', SELF.order.shippingpoint, style='width: 300px') %]</td>
+          </tr>
+
+          <tr>
+            <th align="right">[% 'Ship via' | $T8 %]</th>
+            <td>[% L.input_tag('order.shipvia', SELF.order.shipvia, style='width: 300px') %]</td>
+          </tr>
+
+          <tr>
+            <th align="right">[% 'Transaction description' | $T8 %]</th>
+            <td>[% L.input_tag('order.transaction_description', SELF.order.transaction_description, 'data-validate'=INSTANCE_CONF.get_require_transaction_description_ps ? 'required' : '', style='width: 300px') %]</td>
+          </tr>
+
+          <tr>
+            <th align="right">[% 'Project Number' | $T8 %]</th>
+            <td>[% P.project.picker('order.globalproject_id', SELF.order.globalproject_id, style='width: 300px') %]</td>
+          </tr>
+
+        </table>
+      </td>
+
+      <td align="right">
+        <table>
+
+          <tr>
+            <td colspan="2" align="center" id="data-status-line">[% SELF.order.presenter.status_line %]</td>
+          </tr>
+
+          <tr>
+            <th align="right">[% 'Employee' | $T8 %]</th>
+            <td>[% L.select_tag('order.employee_id',
+              SELF.all_employees,
+              default=(SELF.order.employee_id ? SELF.order.employee_id : SELF.current_employee_id),
+              title_key='safe_name') %]</td>
+          </tr>
+
+          [% IF SELF.cv == 'customer' %]
+          <tr>
+            <th align="right">[% 'Salesman' | $T8 %]</th>
+            <td>[% L.select_tag('order.salesman_id',
+              SELF.all_salesmen,
+              default=(SELF.order.salesman_id ? SELF.order.salesman_id : SELF.current_employee_id),
+              title_key='safe_name') %]</td>
+          </tr>
+          [% END %]
+
+          <tr>
+            <th width="70%" align="right" nowrap>[% 'Delivery Order Number' | $T8 %]</th>
+            <td>[% L.input_tag('order.donumber', SELF.order.donumber, size = 11, onchange='kivi.DeliveryOrder.set_number_in_title(this)') %]</td>
+          </tr>
+
+          <tr>
+            <th width="70%" align="right" nowrap>[% 'Order Number' | $T8 %]</th>
+            <td>[% L.input_tag('order.ordnumber', SELF.order.ordnumber, size = 11) %]</td>
+          </tr>
+
+          <tr>
+            <th width="70%" align="right" nowrap>[% IF SELF.type_data.properties('is_customer') %][% 'Customer Order Number' | $T8 %][% ELSE %][% 'Vendor Order Number' | $T8 %][% END %]</th>
+            <td>[% L.input_tag('order.cusordnumber', SELF.order.cusordnumber, size = 11) %]</td>
+          </tr>
+
+          <tr>
+            <th width="70%" align="right" nowrap>[% 'Delivery Order Date' | $T8 %]</th>
+            <td>[% L.date_tag('order.transdate_as_date', SELF.order.transdate_as_date) %]</td>
+          </tr>
+
+          <tr>
+            <th width="70%" align="right" nowrap>[% 'Reqdate' | $T8 %]</th>
+            <td>[% L.date_tag('order.reqdate_as_date', SELF.order.reqdate_as_date, class=reqdate_class) %]</td>
+          </tr>
+
+          <tr>
+            <th width="70%" align="right" nowrap>[% 'Insert Date' | $T8 %]</th>
+            <td>[% SELF.order.itime_as_date %]</td>
+          </tr>
+        </table>
+
+      </td>
+    </tr>
+  </table>
+
+  [%- PROCESS delivery_order/tabs/_item_input.html SELF=SELF %]
+
+  [% L.button_tag('kivi.DeliveryOrder.open_multi_items_dialog()', LxERP.t8('Add multiple items')) %]
+
+  <table width="100%">
+    <tr>
+      <td>
+        [%- IF SELF.positions_scrollbar_height -%]
+          [%- SET scroll_style = 'style="overflow-y: auto; height:' _ SELF.positions_scrollbar_height _ 'vh;"' -%]
+        [%- ELSE -%]
+          [%- SET scroll_style = '' -%]
+        [%- END -%]
+        <div id="row_table_scroll_id" [%- scroll_style -%]>
+          <table id="row_table_id" width="100%">
+            <thead>
+              <tr class="listheading">
+                <th class="listheading" style='text-align:center' nowrap width="1">
+                  [%- IF MYCONFIG.show_form_details %]
+                    [%- L.img_tag(src="image/collapse.svg", alt=LxERP.t8('Hide all details'), title=LxERP.t8('Hide all details'), id='expand_all', "data-expanded"="1") %]
+                  [%- ELSE %]
+                    [%- L.img_tag(src="image/expand.svg", alt=LxERP.t8('Show all details'), title=LxERP.t8('Show all details'), id='expand_all') %]
+                  [%- END %]
+                </th>
+                <th class="listheading" nowrap width="3" >[%- 'position'     | $T8 %] </th>
+                <th class="listheading" style='text-align:center' nowrap width="1"><img src="image/updown.png" alt="[%- LxERP.t8('reorder item') %]"></th>
+                <th class="listheading" style='text-align:center' nowrap width="1"><img src="image/close.png" alt="[%- LxERP.t8('delete item') %]"></th>
+                [%- IF SELF.show_update_button -%]
+                <th class="listheading" style='text-align:center' nowrap width="1">
+                  [%- L.img_tag(src="image/rotate_cw.svg",
+                                alt=LxERP.t8('Update from master data'),
+                                title= LxERP.t8('Update from master data'),
+                                onclick="if (!confirm('" _ LxERP.t8("Are you sure to update all positions from master data?") _ "')) return false; kivi.DeliveryOrder.update_all_rows_from_master_data();",
+                                id='update_from_master') %]
+                </th>
+                [%- END %]
+                <th id="partnumber_header_id"   class="listheading" nowrap width="15"><a href='#' onClick='javascript:kivi.DeliveryOrder.reorder_items("partnumber")'> [%- 'Partnumber'  | $T8 %]</a></th>
+                [%- IF SELF.search_cvpartnumber -%]
+                <th id="cvpartnumber_header_id" class="listheading" nowrap width="15"><a href='#' onClick='javascript:kivi.DeliveryOrder.reorder_items("cvpartnumber")' > [%- SELF.cv == "customer" ? LxERP.t8('Customer Part Number') : LxERP.t8('Model') %]</a></th>
+                [%- END -%]
+                <th id="partclass_header_id"    class="listheading" nowrap width="2">[%- 'Type'  | $T8 %]</th>
+                <th id="description_header_id"  class="listheading" nowrap           ><a href='#' onClick='javascript:kivi.DeliveryOrder.reorder_items("description")'>[%- 'Description' | $T8 %]</a></th>
+                [%- IF (SELF.type == "sales_order" || SELF.type == "purchase_order") -%]
+                <th id="shipped_qty_header_id"  class="listheading" nowrap width="5" ><a href='#' onClick='javascript:kivi.DeliveryOrder.reorder_items("shipped_qty")'>[%- 'Delivered'   | $T8 %]</a></th>
+                [%- END -%]
+                <th id="qty_header_id"          class="listheading" nowrap width="5" ><a href='#' onClick='javascript:kivi.DeliveryOrder.reorder_items("qty")'>        [%- 'Qty'         | $T8 %]</a></th>
+                <th class="listheading" nowrap width="5" >[%- 'Unit'         | $T8 %] </th>
+                [% IF in_out == 'in' %]
+                <th class="listheading" nowrap width="5" >[%- 'Transfer To Stock' | $T8 %] </th>
+                [% END %]
+                [% IF in_out == 'out' %]
+                <th class="listheading" nowrap width="5" >[%- 'Release From Stock' | $T8 %] </th>
+                [% END %]
+              </tr>
+            </thead>
+
+            [%- FOREACH item = SELF.order.items_sorted %]
+              [%- PROCESS delivery_order/tabs/_row.html ITEM=item ID=(item.id||item.new_fake_id)  -%]
+            [%- END %]
+
+          </table>
+        </div>
+
+      </td>
+    </tr>
+
+    <tr>
+    </tr>
+
+    <tr>
+      <td colspan="100%" width="100%">
+        <table width="100%">
+          <tr>
+            <td>
+              <table>
+                <tr>
+                  <th align="left">[% 'Notes' | $T8 %]</th>
+                  <th align="left">[% 'Internal Notes' | $T8 %]</th>
+                </tr>
+                <tr valign="top">
+                  <td>
+                    [% L.textarea_tag('order.notes', SELF.order.notes, wrap="soft", style="width: 350px; height: 150px", class="texteditor") %]
+                  </td>
+                  <td>
+                    [% L.textarea_tag('order.intnotes', SELF.order.intnotes, wrap="soft", style="width: 350px; height: 150px") %]
+                  </td>
+                </tr>
+              </table>
+            </td>
+
+            <td>
+              <table>
+                <tr>
+                  <th align="right">[% 'Payment Terms' | $T8 %]</th>
+                  <td>[% L.select_tag('order.payment_id',
+                                      SELF.all_payment_terms,
+                                      default = SELF.order.payment_id,
+                                      with_empty = 1,
+                                      title_key = 'description',
+                                      style = 'width: 250px') %]</td>
+                </tr>
+                <tr>
+                  <th align="right">[% 'Delivery Terms' | $T8 %]</th>
+                  <td>[% L.select_tag('order.delivery_term_id',
+                                      SELF.all_delivery_terms,
+                                      default = SELF.order.delivery_term_id,
+                                      with_empty = 1,
+                                      title_key = 'description',
+                                      style = 'width: 250px') %]</td>
+                </tr>
+              </table>
+            </td>
+
+          </tr>
+        </table>
+      </td>
+    </tr>
+
+  </table>
+
+  [% L.hidden_tag('order.taxzone_id', SELF.order.taxzone_id) %]
+
+</div>
+
+[% L.sortable_element('#row_table_id') %]