CsvImport für Lieferscheine
authorBernd Bleßmann <bernd@kivitendo-premium.de>
Fri, 29 May 2020 11:13:39 +0000 (13:13 +0200)
committerBernd Bleßmann <bernd@kivitendo-premium.de>
Wed, 3 Jun 2020 10:38:09 +0000 (12:38 +0200)
SL/Controller/CsvImport.pm
SL/Controller/CsvImport/Base.pm
SL/Controller/CsvImport/BaseMulti.pm
SL/Controller/CsvImport/DeliveryOrder.pm [new file with mode: 0644]
doc/changelog
locale/de/all
locale/en/all
menus/user/00-erp.yaml
templates/webpages/csv_import/_form_delivery_orders.html [new file with mode: 0644]
templates/webpages/csv_import/form.html

index 2a3e062..bb1e8d6 100644 (file)
@@ -20,13 +20,14 @@ use SL::Controller::CsvImport::Inventory;
 use SL::Controller::CsvImport::Shipto;
 use SL::Controller::CsvImport::Project;
 use SL::Controller::CsvImport::Order;
+use SL::Controller::CsvImport::DeliveryOrder;
 use SL::Controller::CsvImport::ARTransaction;
 use SL::JSON;
 use SL::Controller::CsvImport::BankTransaction;
 use SL::BackgroundJob::CsvImport;
 use SL::System::TaskServer;
 
-use List::MoreUtils qw(none);
+use List::MoreUtils qw(any none);
 use List::Util qw(min);
 
 use parent qw(SL::Controller::Base);
@@ -306,7 +307,7 @@ sub check_auth {
 sub check_type {
   my ($self) = @_;
 
-  die "Invalid CSV import type" if none { $_ eq $::form->{profile}->{type} } qw(parts inventories customers_vendors addresses contacts projects orders bank_transactions ar_transactions);
+  die "Invalid CSV import type" if none { $_ eq $::form->{profile}->{type} } qw(parts inventories customers_vendors addresses contacts projects orders delivery_orders bank_transactions ar_transactions);
   $self->type($::form->{profile}->{type});
 }
 
@@ -353,11 +354,12 @@ sub render_inputs {
             : $self->type eq 'inventories'       ? $::locale->text('CSV import: inventories')
             : $self->type eq 'projects'          ? $::locale->text('CSV import: projects')
             : $self->type eq 'orders'            ? $::locale->text('CSV import: orders')
+            : $self->type eq 'delivery_orders'   ? $::locale->text('CSV import: delivery orders')
             : $self->type eq 'bank_transactions' ? $::locale->text('CSV import: bank transactions')
             : $self->type eq 'ar_transactions'   ? $::locale->text('CSV import: ar transactions')
             : die;
 
-  if ($self->{type} eq 'customers_vendors' or $self->{type} eq 'orders' or $self->{type} eq 'ar_transactions' ) {
+  if ( any { $_ eq $self->{type} } qw(customers_vendors orders delivery_orders ar_transactions) ) {
     $self->all_taxzones(SL::DB::Manager::TaxZone->get_all_sorted(query => [ obsolete => 0 ]));
   };
 
@@ -721,6 +723,7 @@ sub init_worker {
        : $self->{type} eq 'inventories'       ? SL::Controller::CsvImport::Inventory->new(@args)
        : $self->{type} eq 'projects'          ? SL::Controller::CsvImport::Project->new(@args)
        : $self->{type} eq 'orders'            ? SL::Controller::CsvImport::Order->new(@args)
+       : $self->{type} eq 'delivery_orders'   ? SL::Controller::CsvImport::DeliveryOrder->new(@args)
        : $self->{type} eq 'bank_transactions' ? SL::Controller::CsvImport::BankTransaction->new(@args)
        : $self->{type} eq 'ar_transactions'   ? SL::Controller::CsvImport::ARTransaction->new(@args)
        :                                        die "Program logic error";
index 9e8956f..e3c119d 100644 (file)
@@ -551,6 +551,7 @@ sub save_objects {
           push @{ $entry->{errors} }, $::locale->text('Error when saving: #1', $object->db->error);
         } else {
           $self->_save_history($object);
+          $self->save_additions($object);
           $self->controller->num_imported($self->controller->num_imported + 1);
         }
       }
@@ -592,14 +593,25 @@ sub clean_fields {
   return @cleaned_fields;
 }
 
+sub save_additions {
+  my ($self, $object) = @_;
+
+  # Can be overridden by derived specialized importer classes to save
+  # additional tables (e.g. record links).
+  # This sub is called after the object is saved successfully in an transaction.
+
+  return;
+}
+
 sub _save_history {
   my ($self, $object) = @_;
 
-  if (any { $self->controller->{type} && $_ eq $self->controller->{type} } qw(parts customers_vendors orders ar_transactions)) {
+  if (any { $self->controller->{type} && $_ eq $self->controller->{type} } qw(parts customers_vendors orders delivery_orders ar_transactions)) {
     my $snumbers = $self->controller->{type} eq 'parts'             ? 'partnumber_' . $object->partnumber
                  : $self->controller->{type} eq 'customers_vendors' ?
                      ($self->table eq 'customer' ? 'customernumber_' . $object->customernumber : 'vendornumber_' . $object->vendornumber)
                  : $self->controller->{type} eq 'orders'            ? 'ordnumber_' . $object->ordnumber
+                 : $self->controller->{type} eq 'delivery_orders'   ? 'donumber_'  . $object->donumber
                  : $self->controller->{type} eq 'ar_transactions'   ? 'invnumber_' . $object->invnumber
                  : '';
 
@@ -607,6 +619,9 @@ sub _save_history {
     if ($self->controller->{type} eq 'orders') {
       $what_done = $object->customer_id ? 'sales_order' : 'purchase_order';
     }
+    if ($self->controller->{type} eq 'delivery_orders') {
+      $what_done = $object->customer_id ? 'sales_delivery_order' : 'purchase_delivery_order';
+    }
 
     SL::DB::History->new(
       trans_id    => $object->id,
index b975394..5168ae2 100644 (file)
@@ -94,6 +94,17 @@ sub run {
   $::myconfig{numberformat} = $old_numberformat;
 }
 
+sub init_manager_class {
+  my ($self) = @_;
+
+  my @manager_classes;
+  foreach my $class (@{ $self->class }) {
+    $class =~ m/^SL::DB::(.+)/;
+    push @manager_classes, "SL::DB::Manager::" . $1;
+  }
+  $self->manager_class(\@manager_classes);
+}
+
 sub add_columns {
   my ($self, $row_ident, @columns) = @_;
 
diff --git a/SL/Controller/CsvImport/DeliveryOrder.pm b/SL/Controller/CsvImport/DeliveryOrder.pm
new file mode 100644 (file)
index 0000000..62ecb11
--- /dev/null
@@ -0,0 +1,1226 @@
+package SL::Controller::CsvImport::DeliveryOrder;
+
+
+use strict;
+
+use List::Util qw(first);
+use List::MoreUtils qw(any none uniq);
+use DateTime;
+
+use SL::Controller::CsvImport::Helper::Consistency;
+use SL::DB::DeliveryOrder;
+use SL::DB::DeliveryOrderItem;
+use SL::DB::DeliveryOrderItemsStock;
+use SL::DB::Part;
+use SL::DB::PaymentTerm;
+use SL::DB::Contact;
+use SL::DB::PriceFactor;
+use SL::DB::Pricegroup;
+use SL::DB::Shipto;
+use SL::DB::Unit;
+use SL::DB::Inventory;
+use SL::DB::TransferType;
+use SL::DBUtils;
+use SL::PriceSource;
+use SL::TransNumber;
+use SL::Util qw(trim);
+
+use parent qw(SL::Controller::CsvImport::BaseMulti);
+
+
+use Rose::Object::MakeMethods::Generic
+(
+ 'scalar --get_set_init' => [ qw(settings languages_by all_parts parts_by part_counts_by
+                                 contacts_by ct_shiptos_by
+                                 price_factors_by pricegroups_by units_by
+                                 warehouses_by bins_by transfer_types_by) ],
+);
+
+
+sub init_class {
+  my ($self) = @_;
+  $self->class(['SL::DB::DeliveryOrder', 'SL::DB::DeliveryOrderItem', 'SL::DB::DeliveryOrderItemsStock']);
+}
+
+sub set_profile_defaults {
+  my ($self) = @_;
+
+  $self->controller->profile->_set_defaults(
+    order_column         => $::locale->text('DeliveryOrder'),
+    item_column          => $::locale->text('OrderItem'),
+    stock_column         => $::locale->text('StockInfo'),
+    ignore_faulty_positions => 0,
+  );
+};
+
+sub init_settings {
+  my ($self) = @_;
+
+  return { map { ( $_ => $self->controller->profile->get($_) ) } qw(order_column item_column stock_column ignore_faulty_positions) };
+}
+
+sub init_cvar_configs_by {
+  my ($self) = @_;
+
+  my $item_cvar_configs = SL::DB::Manager::CustomVariableConfig->get_all(where => [ module => 'IC' ]);
+  $item_cvar_configs = [grep { $_->has_flag('editable') } @{ $item_cvar_configs }];
+
+  my $ccb;
+  $ccb->{class}->{$self->class->[0]}        = [];
+  $ccb->{class}->{$self->class->[1]}        = $item_cvar_configs;
+  $ccb->{class}->{$self->class->[2]}        = [];
+  $ccb->{row_ident}->{$self->_order_column} = [];
+  $ccb->{row_ident}->{$self->_item_column}  = $item_cvar_configs;
+  $ccb->{row_ident}->{$self->_stock_column} = [];
+
+  return $ccb;
+}
+
+sub init_profile {
+  my ($self) = @_;
+
+  my $profile = $self->SUPER::init_profile;
+
+  # SUPER::init_profile sets row_ident to the translated class name
+  # overwrite it with the user specified settings
+  foreach my $p (@{ $profile }) {
+    $p->{row_ident} = $self->_order_column if $p->{class} eq $self->class->[0];
+    $p->{row_ident} = $self->_item_column  if $p->{class} eq $self->class->[1];
+    $p->{row_ident} = $self->_stock_column if $p->{class} eq $self->class->[2];
+  }
+
+  foreach my $p (@{ $profile }) {
+    my $prof = $p->{profile};
+    if ($p->{row_ident} eq $self->_order_column) {
+      # no need to handle
+      delete @{$prof}{qw(oreqnumber)};
+    }
+    if ($p->{row_ident} eq $self->_item_column) {
+      # no need to handle
+      delete @{$prof}{qw(delivery_order_id)};
+    }
+    if ($p->{row_ident} eq $self->_stock_column) {
+      # no need to handle
+      delete @{$prof}{qw(delivery_order_item_id)};
+      delete @{$prof}{qw(bestbefore)} if !$::instance_conf->get_show_bestbefore;
+    }
+  }
+
+  return $profile;
+}
+
+sub init_existing_objects {
+  my ($self) = @_;
+
+  # only use objects of main class (the first one)
+  eval "require " . $self->class->[0];
+  $self->existing_objects($self->manager_class->[0]->get_all);
+}
+
+sub get_duplicate_check_fields {
+  return {
+    donumber => {
+      label     => $::locale->text('Delivery Order Number'),
+      default   => 1,
+      std_check => 1,
+      maker     => sub {
+        my ($object, $worker) = @_;
+        return if ref $object ne $worker->class->[0];
+        return $object->donumber;
+      },
+    },
+  };
+}
+
+sub check_std_duplicates {
+  my $self = shift;
+
+  my $duplicates = {};
+
+  my $all_fields = $self->get_duplicate_check_fields();
+
+  foreach my $key (keys(%{ $all_fields })) {
+    if ( $self->controller->profile->get('duplicates_'. $key) && (!exists($all_fields->{$key}->{std_check}) || $all_fields->{$key}->{std_check} )  ) {
+      $duplicates->{$key} = {};
+    }
+  }
+
+  my @duplicates_keys = keys(%{ $duplicates });
+
+  if ( !scalar(@duplicates_keys) ) {
+    return;
+  }
+
+  if ( $self->controller->profile->get('duplicates') eq 'check_db' ) {
+    foreach my $object (@{ $self->existing_objects }) {
+      foreach my $key (@duplicates_keys) {
+        my $value = exists($all_fields->{$key}->{maker}) ? $all_fields->{$key}->{maker}->($object, $self) : $object->$key;
+        $duplicates->{$key}->{$value} = 'db';
+      }
+    }
+  }
+
+  # only check order rows
+  foreach my $entry (@{ $self->controller->data }) {
+    if ($entry->{raw_data}->{datatype} ne $self->_order_column) {
+      next;
+    }
+    if ( @{ $entry->{errors} } ) {
+      next;
+    }
+
+    my $object = $entry->{object};
+
+    foreach my $key (@duplicates_keys) {
+      my $value = exists($all_fields->{$key}->{maker}) ? $all_fields->{$key}->{maker}->($object, $self) : $object->$key;
+
+      if ( exists($duplicates->{$key}->{$value}) ) {
+        push(@{ $entry->{errors} }, $duplicates->{$key}->{$value} eq 'db' ? $::locale->text('Duplicate in database') : $::locale->text('Duplicate in CSV file'));
+        last;
+      } else {
+        $duplicates->{$key}->{$value} = 'csv';
+      }
+
+    }
+  }
+}
+
+sub setup_displayable_columns {
+  my ($self) = @_;
+
+  $self->SUPER::setup_displayable_columns;
+
+  $self->add_cvar_columns_to_displayable_columns($self->_order_column);
+
+  $self->add_displayable_columns($self->_order_column,
+                                 { name => 'datatype',                description => $self->_order_column . ' [1]'                            },
+                                 { name => 'closed',                  description => $::locale->text('Closed')                                },
+                                 { name => 'contact',                 description => $::locale->text('Contact Person (name)')                 },
+                                 { name => 'cp_id',                   description => $::locale->text('Contact Person (database ID)')          },
+                                 { name => 'currency',                description => $::locale->text('Currency')                              },
+                                 { name => 'currency_id',             description => $::locale->text('Currency (database ID)')                },
+                                 { name => 'customer',                description => $::locale->text('Customer (name)')                       },
+                                 { name => 'customernumber',          description => $::locale->text('Customer Number')                       },
+                                 { name => 'customer_id',             description => $::locale->text('Customer (database ID)')                },
+                                 { name => 'cusordnumber',            description => $::locale->text('Customer Order Number')                 },
+                                 { name => 'delivered',               description => $::locale->text('Delivered')                             },
+                                 { name => 'delivery_term',           description => $::locale->text('Delivery terms (name)')                 },
+                                 { name => 'delivery_term_id',        description => $::locale->text('Delivery terms (database ID)')          },
+                                 { name => 'department_id',           description => $::locale->text('Department (database ID)')              },
+                                 { name => 'department',              description => $::locale->text('Department (description)')              },
+                                 { name => 'donumber',                description => $::locale->text('Delivery Order Number')                 },
+                                 { name => 'employee_id',             description => $::locale->text('Employee (database ID)')                },
+                                 { name => 'globalproject',           description => $::locale->text('Document Project (description)')        },
+                                 { 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 => 'ordnumber',               description => $::locale->text('Order Number')                          },
+                                 { name => 'payment',                 description => $::locale->text('Payment terms (name)')                  },
+                                 { name => 'payment_id',              description => $::locale->text('Payment terms (database ID)')           },
+                                 { name => 'reqdate',                 description => $::locale->text('Reqdate')                               },
+                                 { name => 'salesman_id',             description => $::locale->text('Salesman (database ID)')                },
+                                 { name => 'shippingpoint',           description => $::locale->text('Shipping Point')                        },
+                                 { name => 'shipvia',                 description => $::locale->text('Ship via')                              },
+                                 { name => 'shipto_id',               description => $::locale->text('Ship to (database ID)')                 },
+                                 { name => 'taxincluded',             description => $::locale->text('Tax Included')                          },
+                                 { name => 'taxzone',                 description => $::locale->text('Tax zone (description)')                },
+                                 { name => 'taxzone_id',              description => $::locale->text('Tax zone (database ID)')                },
+                                 { name => 'transaction_description', description => $::locale->text('Transaction description')               },
+                                 { name => 'transdate',               description => $::locale->text('Order Date')                            },
+                                 { name => 'vendor',                  description => $::locale->text('Vendor (name)')                         },
+                                 { name => 'vendornumber',            description => $::locale->text('Vendor Number')                         },
+                                 { name => 'vendor_id',               description => $::locale->text('Vendor (database ID)')                  },
+                                );
+
+  $self->add_cvar_columns_to_displayable_columns($self->_item_column);
+
+  $self->add_displayable_columns($self->_item_column,
+                                 { name => 'datatype',        description => $self->_item_column . ' [1]'                  },
+                                 { name => 'cusordnumber',    description => $::locale->text('Customer Order Number')      },
+                                 { name => 'description',     description => $::locale->text('Description')                },
+                                 { name => 'discount',        description => $::locale->text('Discount')                   },
+                                 { name => 'lastcost',        description => $::locale->text('Lastcost')                   },
+                                 { name => 'longdescription', description => $::locale->text('Long Description')           },
+                                 { name => 'ordnumber',       description => $::locale->text('Order Number')               },
+                                 { name => 'partnumber',      description => $::locale->text('Part Number')                },
+                                 { name => 'parts_id',        description => $::locale->text('Part (database ID)')         },
+                                 { name => 'position',        description => $::locale->text('position')                   },
+                                 { name => 'price_factor',    description => $::locale->text('Price factor (name)')        },
+                                 { name => 'price_factor_id', description => $::locale->text('Price factor (database ID)') },
+                                 { name => 'pricegroup',      description => $::locale->text('Price group (name)')         },
+                                 { name => 'pricegroup_id',   description => $::locale->text('Price group (database ID)')  },
+                                 { name => 'project',         description => $::locale->text('Project (description)')      },
+                                 { name => 'projectnumber',   description => $::locale->text('Project (number)')           },
+                                 { name => 'project_id',      description => $::locale->text('Project (database ID)')      },
+                                 { name => 'qty',             description => $::locale->text('Quantity')                   },
+                                 { name => 'reqdate',         description => $::locale->text('Reqdate')                    },
+                                 { name => 'sellprice',       description => $::locale->text('Sellprice')                  },
+                                 { name => 'serialnumber',    description => $::locale->text('Serial No.')                 },
+                                 { name => 'transdate',       description => $::locale->text('Order Date')                 },
+                                 { name => 'unit',            description => $::locale->text('Unit')                       },
+                                );
+
+  $self->add_cvar_columns_to_displayable_columns($self->_stock_column);
+
+  $self->add_displayable_columns($self->_stock_column,
+                                 { name => 'datatype',     description => $self->_stock_column . ' [1]'              },
+                                 { name => 'warehouse',    description => $::locale->text('Warehouse')               },
+                                 { name => 'warehouse_id', description => $::locale->text('Warehouse (database ID)') },
+                                 { name => 'bin',          description => $::locale->text('Bin')                     },
+                                 { name => 'bin_id',       description => $::locale->text('Bin (database ID)')       },
+                                 { name => 'chargenumber', description => $::locale->text('Charge number')           },
+                                 { name => 'qty',          description => $::locale->text('Quantity')                },
+                                 { name => 'unit',         description => $::locale->text('Unit')                    },
+                                );
+  if ($::instance_conf->get_show_bestbefore) {
+    $self->add_displayable_columns($self->_stock_column,
+                                   { name => 'bestbefore', description => $::locale->text('Best Before') });
+  }
+}
+
+
+sub init_languages_by {
+  my ($self) = @_;
+
+  return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $self->all_languages } } ) } qw(id description article_code) };
+}
+
+sub init_all_parts {
+  my ($self) = @_;
+
+  return SL::DB::Manager::Part->get_all(where => [or => [ obsolete => 0, obsolete => undef ]]);
+}
+
+sub init_parts_by {
+  my ($self) = @_;
+
+  return { map { my $col = $_; ( $col => { map { ( trim($_->$col) => $_ ) } @{ $self->all_parts } } ) } qw(id partnumber ean description) };
+}
+
+sub init_part_counts_by {
+  my ($self) = @_;
+
+  my $part_counts_by;
+
+  $part_counts_by->{ean}->        {trim($_->ean)}++         for @{ $self->all_parts };
+  $part_counts_by->{description}->{trim($_->description)}++ for @{ $self->all_parts };
+
+  return $part_counts_by;
+}
+
+sub init_contacts_by {
+  my ($self) = @_;
+
+  my $all_contacts = SL::DB::Manager::Contact->get_all;
+
+  my $cby;
+  # by customer/vendor id  _and_  contact person id
+  $cby->{'cp_cv_id+cp_id'}   = { map { ( $_->cp_cv_id . '+' . $_->cp_id   => $_ ) } @{ $all_contacts } };
+  # by customer/vendor id  _and_  contact person name
+  $cby->{'cp_cv_id+cp_name'} = { map { ( $_->cp_cv_id . '+' . $_->cp_name => $_ ) } @{ $all_contacts } };
+
+  return $cby;
+}
+
+sub init_ct_shiptos_by {
+  my ($self) = @_;
+
+  my $all_ct_shiptos = SL::DB::Manager::Shipto->get_all(query => [module => 'CT']);
+
+  my $sby;
+  # by trans_id  _and_  shipto_id
+  $sby->{'trans_id+shipto_id'} = { map { ( $_->trans_id . '+' . $_->shipto_id => $_ ) } @{ $all_ct_shiptos } };
+
+  return $sby;
+}
+
+sub init_price_factors_by {
+  my ($self) = @_;
+
+  my $all_price_factors = SL::DB::Manager::PriceFactor->get_all;
+  return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $all_price_factors } } ) } qw(id description) };
+}
+
+sub init_pricegroups_by {
+  my ($self) = @_;
+
+  my $all_pricegroups = SL::DB::Manager::Pricegroup->get_all;
+  return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $all_pricegroups } } ) } qw(id pricegroup) };
+}
+
+sub init_units_by {
+  my ($self) = @_;
+
+  my $all_units = SL::DB::Manager::Unit->get_all;
+  return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $all_units } } ) } qw(name) };
+}
+
+sub init_warehouses_by {
+  my ($self) = @_;
+
+  my $all_warehouses = SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef ]]);
+  return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $all_warehouses } } ) } qw(id description) };
+}
+
+sub init_bins_by {
+  my ($self) = @_;
+
+  my $all_bins = SL::DB::Manager::Bin->get_all();
+  my $bins_by;
+  $bins_by->{_wh_id_and_id_ident()}          = { map { ( _wh_id_and_id_maker($_->warehouse_id, $_->id)                   => $_ ) } @{ $all_bins } };
+  $bins_by->{_wh_id_and_description_ident()} = { map { ( _wh_id_and_description_maker($_->warehouse_id, $_->description) => $_ ) } @{ $all_bins } };
+
+  return $bins_by;
+}
+
+sub init_transfer_types_by {
+  my ($self) = @_;
+
+  my $all_transfer_types = SL::DB::Manager::TransferType->get_all();
+  my $transfer_types_by;
+  $transfer_types_by->{_transfer_type_dir_and_description_ident()} = {
+    map { ( _transfer_type_dir_and_description_maker($_->direction, $_->description) => $_ ) } @{ $all_transfer_types }
+  };
+
+  return $transfer_types_by;
+}
+
+sub check_objects {
+  my ($self) = @_;
+
+  $self->controller->track_progress(phase => 'building data', progress => 0);
+
+  my $i = 0;
+  my $num_data = scalar @{ $self->controller->data };
+  my $order_entry;
+  my $item_entry;
+  foreach my $entry (@{ $self->controller->data }) {
+    $self->controller->track_progress(progress => $i/$num_data * 100) if $i % 100 == 0;
+
+    $entry->{info_data}->{datatype} = $entry->{raw_data}->{datatype};
+
+    if ($entry->{raw_data}->{datatype} eq $self->_order_column) {
+      $self->handle_order($entry);
+      $order_entry = $entry;
+    } elsif ($entry->{raw_data}->{datatype} eq $self->_item_column && $entry->{object}->can('part')) {
+      $self->handle_item($entry, $order_entry);
+      $item_entry = $entry;
+    } elsif ($entry->{raw_data}->{datatype} eq $self->_stock_column) {
+      $self->handle_stock($entry, $item_entry, $order_entry);
+      push @{ $order_entry->{errors} }, $::locale->text('Error: Stock problem') if scalar(@{$entry->{errors}}) > 0;
+    } else {
+      $order_entry = undef;
+      $item_entry  = undef;
+    }
+
+    $self->handle_cvars($entry, sub_module => 'delivery_order_items');
+
+  } continue {
+    $i++;
+  }
+
+  $self->add_info_columns($self->_order_column,
+                          { header => $::locale->text('Data type'), method => 'datatype' });
+  $self->add_info_columns($self->_item_column,
+                          { header => $::locale->text('Data type'), method => 'datatype' });
+  $self->add_info_columns($self->_stock_column,
+                          { header => $::locale->text('Data type'), method => 'datatype' });
+
+  $self->add_info_columns($self->_order_column,
+                          { header => $::locale->text('Customer/Vendor'), method => 'vc_name' });
+  # Todo: access via ->[0] ok? Better: search first order column and use this
+  $self->add_columns($self->_order_column,
+                     map { "${_}_id" } grep { exists $self->controller->data->[0]->{raw_data}->{$_} } qw(payment delivery_term language department globalproject taxzone cp currency));
+  $self->add_columns($self->_order_column, 'globalproject_id') if exists $self->controller->data->[0]->{raw_data}->{globalprojectnumber};
+  $self->add_columns($self->_order_column, 'cp_id')            if exists $self->controller->data->[0]->{raw_data}->{contact};
+
+  $self->add_info_columns($self->_item_column,
+                          { header => $::locale->text('Part Number'), method => 'partnumber' });
+  # Todo: access via ->[1] ok? Better: search first item column and use this
+  $self->add_columns($self->_item_column,
+                     map { "${_}_id" } grep { exists $self->controller->data->[1]->{raw_data}->{$_} } qw(project price_factor pricegroup));
+  $self->add_columns($self->_item_column, 'project_id') if exists $self->controller->data->[1]->{raw_data}->{projectnumber};
+
+  $self->add_cvar_raw_data_columns();
+
+
+  # Check overall qtys for sales delivery orders, because they are
+  # stocked out in the end and a stock underrun can occure.
+  # Todo: let it work even with bestbefore turned off.
+  $order_entry = undef;
+  $item_entry  = undef;
+  my %wanted_qtys_by_part_wh_bin_charge_bestbefore;
+  my %stock_entries_with_part_wh_bin_charge_bestbefore;
+  my %order_entries_with_part_wh_bin_charge_bestbefore;
+  foreach my $entry (@{ $self->controller->data }) {
+    if ($entry->{raw_data}->{datatype} eq $self->_order_column) {
+      if (scalar(@{ $entry->{errors} }) || !$entry->{object}->is_sales) {
+        $order_entry = undef;
+        $item_entry  = undef;
+        next;
+      }
+      $order_entry = $entry;
+
+    } elsif (defined $order_entry && $entry->{raw_data}->{datatype} eq $self->_item_column) {
+      if (scalar(@{ $entry->{errors} })) {
+        $item_entry = undef;
+        next;
+      }
+      $item_entry = $entry;
+
+    } elsif (defined $item_entry && $entry->{raw_data}->{datatype} eq $self->_stock_column) {
+      my $object = $entry->{object};
+      my $key = join('+',
+                     $item_entry->{object}->parts_id,
+                     $object->warehouse_id,
+                     $object->bin_id,
+                     $object->chargenumber,
+                     $object->bestbefore);
+      $wanted_qtys_by_part_wh_bin_charge_bestbefore{$key} += $object->qty;
+      push @{$order_entries_with_part_wh_bin_charge_bestbefore{$key}}, $order_entry;
+      push @{$stock_entries_with_part_wh_bin_charge_bestbefore{$key}}, $entry;
+    }
+  }
+
+  foreach my $key (keys %wanted_qtys_by_part_wh_bin_charge_bestbefore) {
+    my ($parts_id, $wh_id, $bin_id, $chargenumber, $bestbefore) = split '\+', $key;
+    my $qty = $self->get_stocked_qty($parts_id, $wh_id, $bin_id, $chargenumber, $bestbefore);
+    if ($wanted_qtys_by_part_wh_bin_charge_bestbefore{$key} > $qty) {
+
+      foreach my $stock_entry (@{ $stock_entries_with_part_wh_bin_charge_bestbefore{$key} }) {
+        push @{ $stock_entry->{errors} }, $::locale->text('Error: Stocking out would result in stock underrun');
+      }
+
+      foreach my $order_entry (uniq @{ $order_entries_with_part_wh_bin_charge_bestbefore{$key} }) {
+        my $part            = $self->parts_by->{id}->{$parts_id}->displayable_name;
+        my $stock           = $self->bins_by->{_wh_id_and_id_ident()}->{_wh_id_and_id_maker($wh_id, $bin_id)}->full_description;
+        my $bestbefore_obj  = $::locale->parse_date_to_object($bestbefore, dateformat=>'yyyy-mm-dd');
+        my $bestbefore_text = $bestbefore_obj? $::locale->parse_date_to_object($bestbefore_obj, dateformat=>'yyyy-mm-dd')->to_kivitendo: '-';
+        my $wanted_qty      = $wanted_qtys_by_part_wh_bin_charge_bestbefore{$key};
+        my $details_text    = sprintf('%s (%s / %s / %s): %s > %s',
+                                      $part,
+                                      $stock,
+                                      $chargenumber,
+                                      $bestbefore_text,
+                                      $::form->format_amount(\%::myconfig, $wanted_qty,  2),
+                                      $::form->format_amount(\%::myconfig, $qty, 2));
+        push @{ $order_entry->{errors} }, $::locale->text('Error: Stocking out would result in stock underrun: #1', $details_text);
+      }
+
+    }
+  }
+
+}
+
+sub handle_order {
+  my ($self, $entry) = @_;
+
+  my $object = $entry->{object};
+
+  $object->orderitems([]);
+
+  $self->handle_order_sources($entry);
+  my $first_source_order = $object->{source_orders}->[0];
+
+  my $vc_obj;
+  if (any { $entry->{raw_data}->{$_} } qw(customer customernumber customer_id)) {
+    $self->check_vc($entry, 'customer_id');
+    $vc_obj = SL::DB::Customer->new(id => $object->customer_id)->load if $object->customer_id;
+
+  } elsif (any { $entry->{raw_data}->{$_} } qw(vendor vendornumber vendor_id)) {
+    $self->check_vc($entry, 'vendor_id');
+    $vc_obj = SL::DB::Vendor->new(id => $object->vendor_id)->load if $object->vendor_id;
+
+  } else {
+    # customer / vendor from (first) source order if not given
+    if ($first_source_order) {
+      if ($first_source_order->customer) {
+        $vc_obj = $first_source_order->customer;
+        $object->customer($first_source_order->customer);
+      } elsif ($first_source_order->vendor) {
+        $vc_obj = $first_source_order->vendor;
+        $object->vendor($first_source_order->vendor);
+      }
+    }
+  }
+
+  if (!$vc_obj) {
+    push @{ $entry->{errors} }, $::locale->text('Error: Customer/vendor missing');
+  }
+
+  $self->handle_is_sales($entry);
+  $self->check_contact($entry);
+  $self->check_language($entry);
+  $self->check_payment($entry);
+  $self->check_delivery_term($entry);
+  $self->check_department($entry);
+  $self->check_project($entry, global => 1);
+  $self->check_ct_shipto($entry);
+  $self->check_taxzone($entry);
+  $self->check_currency($entry, take_default => 0);
+
+  # copy from (first) source order if not given
+  # if no source order, then copy some values from customer/vendor
+  if ($first_source_order) {
+    foreach (qw(cusordnumber notes intnotes shippingpoint shipvia
+                transaction_description currency_id delivery_term_id
+                department_id language_id payment_id globalproject_id shipto_id
+                taxzone_id)) {
+      $object->$_($first_source_order->$_) unless $object->$_;
+    }
+  } elsif ($vc_obj) {
+    foreach (qw(currency_id delivery_term_id language_id payment_id taxzone_id)) {
+      $object->$_($vc_obj->$_) unless $object->$_;
+    }
+    $object->intnotes($vc_obj->notes) unless $object->intnotes;
+  }
+
+  $self->handle_salesman($entry);
+  $self->handle_employee($entry);
+}
+
+sub handle_item {
+  my ($self, $entry, $order_entry) = @_;
+
+  return unless $order_entry;
+
+  my $order_obj = $order_entry->{object};
+  my $object    = $entry->{object};
+  $object->delivery_order_stock_entries([]);
+
+  if (!$self->check_part($entry)) {
+    if ($self->controller->profile->get('ignore_faulty_positions')) {
+      push @{ $order_entry->{information} }, $::locale->text('Warning: Faulty position ignored');
+    } else {
+      push @{ $order_entry->{errors} }, $::locale->text('Error: Faulty position in this delivery order');
+    }
+    return;
+  }
+
+  $order_obj->add_items($object);
+
+  my $part_obj = SL::DB::Part->new(id => $object->parts_id)->load;
+
+  $self->handle_item_source($entry, $order_entry);
+  $object->position($object->{source_item}->position) if $object->{source_item};
+
+  $self->handle_unit($entry);
+
+  # copy from part if not given
+  $object->description($part_obj->description) unless $object->description;
+  $object->longdescription($part_obj->notes)   unless $object->longdescription;
+  $object->lastcost($part_obj->lastcost)       unless defined $object->lastcost;
+
+  $self->check_project($entry, global => 0);
+  $self->check_price_factor($entry);
+  $self->check_pricegroup($entry);
+
+  $self->handle_sellprice($entry, $order_entry);
+  $self->handle_discount($entry, $order_entry);
+
+  push @{ $order_entry->{errors} }, $::locale->text('Error: Faulty position in this delivery order') if scalar(@{$entry->{errors}}) > 0;
+}
+
+sub handle_stock {
+  my ($self, $entry, $item_entry, $order_entry) = @_;
+
+  return unless $item_entry;
+
+  my $item_obj  = $item_entry->{object};
+  return unless $item_obj->part;
+
+  my $order_obj = $order_entry->{object};
+  my $object    = $entry->{object};
+
+  $item_obj->add_delivery_order_stock_entries($object);
+
+  $self->check_warehouse($entry);
+  $self->check_bin($entry);
+
+  $self->handle_unit($entry, $item_obj->part);
+
+  # check if enough is stocked
+  # not necessary, because overall stock underrun is checked later
+  # if ($order_obj->is_sales) {
+  #   my $stocked_qty = $self->get_stocked_qty($item_obj->parts_id,
+  #                                            $object->warehouse_id,
+  #                                            $object->bin_id,
+  #                                            $object->chargenumber,
+  #                                            $object->bestbefore);
+  #   if ($stocked_qty < $object->qty) {
+  #     push @{ $entry->{errors} }, $::locale->text('Error: Not enough parts in stock');
+  #   }
+  # }
+
+  my ($stock_info_entry, $part) = @_;
+
+  # Todo: option: should stock?
+  if (1) {
+    my $tt_key = $order_obj->is_sales
+               ? _transfer_type_dir_and_description_maker('out', 'shipped')
+               : _transfer_type_dir_and_description_maker('in', 'stock');
+    my $trans_type_id = $self->transfer_types_by->{_transfer_type_dir_and_description_ident()}{$tt_key}->id;
+
+    my $qty = $order_obj->is_sales ? -1*($object->qty) : $object->qty;
+    my $inventory = SL::DB::Inventory->new(
+      parts_id      => $item_obj->parts_id,
+      warehouse_id  => $object->warehouse_id,
+      bin_id        => $object->bin_id,
+      trans_type_id => $trans_type_id,
+      qty           => $qty,
+      chargenumber  => $object->chargenumber,
+      employee_id   => $order_obj->employee_id,
+      shippingdate  => ($order_obj->reqdate || DateTime->today_local),
+      comment       => $order_obj->transaction_description,
+      project_id    => ($order_obj->globalproject_id || $item_obj->project_id),
+    );
+    $inventory->bestbefore($object->bestbefore) if $::instance_conf->get_show_bestbefore;
+    $object->{inventory_obj} = $inventory;
+    $order_obj->delivered(1);
+  }
+}
+
+sub handle_is_sales {
+  my ($self, $entry) = @_;
+
+  if (!exists $entry->{raw_data}->{is_sales}) {
+    $entry->{object}->is_sales(!!$entry->{object}->customer_id);
+  }
+}
+
+sub handle_order_sources {
+  my ($self, $entry) = @_;
+
+  my $record = $entry->{object};
+
+  $record->{source_orders} = [];
+  return $record->{source_orders} if !$record->ordnumber;
+
+  my @order_numbers = split ' ', $record->ordnumber;
+
+  my $orders = SL::DB::Manager::Order->get_all(where => [ordnumber => \@order_numbers]);
+
+  if (scalar @$orders == 0) {
+    push @{ $entry->{errors} }, $::locale->text('Error: Source order not found');
+  } elsif (scalar @$orders > 1) {
+    push @{ $entry->{errors} }, $::locale->text('Error: More than one source order found');
+  }
+
+  $record->{source_orders} = $orders;
+}
+
+sub handle_item_source {
+  my ($self, $entry, $record_entry) = @_;
+
+  my $item   = $entry->{object};
+  my $record = $record_entry->{object};
+
+  return if !@{ $record->{source_orders} };
+
+  foreach my $order (@{ $record->{source_orders} }) {
+    $item->{source_item} = first { $item->parts_id == $_->parts_id && $item->qty == $_->qty} @{ $order->items_sorted };
+    last if $item->{source_item};
+  }
+}
+
+sub handle_unit {
+  my ($self, $entry, $part) = @_;
+
+  my $object = $entry->{object};
+
+  $part ||= $object->part;
+
+  # Set unit from part if not given.
+  if (!$object->unit) {
+    $object->unit($part->unit);
+    return 1;
+  }
+
+  # Check whether or not unit is valid.
+  if ($object->unit && !$self->units_by->{name}->{ $object->unit }) {
+    push @{ $entry->{errors} }, $::locale->text('Error: Invalid unit');
+    return 0;
+  }
+
+  # Check whether unit is convertible to parts unit
+  if (none { $object->unit eq $_ } map { $_->name } @{ $part->unit_obj->convertible_units }) {
+    push @{ $entry->{errors} }, $::locale->text('Error: Invalid unit');
+    return 0;
+  }
+
+  return 1;
+}
+
+sub handle_sellprice {
+  my ($self, $entry, $record_entry) = @_;
+
+  my $item   = $entry->{object};
+  my $record = $record_entry->{object};
+
+  return if !$record->customervendor;
+
+  # If sellprice is given, set price source to pricegroup if given or to none.
+  if (exists $entry->{raw_data}->{sellprice}) {
+    my $price_source      = SL::PriceSource->new(record_item => $item, record => $record);
+    my $price_source_spec = $item->pricegroup_id ? 'pricegroup' . '/' . $item->pricegroup_id : '';
+    my $price             = $price_source->price_from_source($price_source_spec);
+    $item->active_price_source($price->source);
+
+  } else {
+
+    if ($item->{source_item}) {
+      # Set sellprice from source order item if not given. Convert with respect to unit.
+      my $sellprice = $item->{source_item}->sellprice;
+      if ($item->unit ne $item->{source_item}->unit) {
+        $sellprice = $item->unit_obj->convert_to($sellprice, $item->{source_item}->unit_obj);
+      }
+      $item->sellprice($sellprice);
+      $item->active_price_source($item->{source_item}->active_price_source);
+
+    } else {
+      # Set sellprice the best price of price source
+      my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
+      my $price        = $price_source->best_price;
+      if ($price) {
+        $item->sellprice($price->price);
+        $item->active_price_source($price->source);
+      } else {
+        $item->sellprice(0);
+        $item->active_price_source($price_source->price_from_source('')->source);
+      }
+    }
+  }
+}
+
+sub handle_discount {
+  my ($self, $entry, $record_entry) = @_;
+
+  my $item   = $entry->{object};
+  my $record = $record_entry->{object};
+
+  return if !$record->customervendor;
+
+  # If discount is given, set discount to none.
+  if (exists $entry->{raw_data}->{discount}) {
+    my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
+    my $discount     = $price_source->price_from_source('');
+    $item->active_discount_source($discount->source);
+
+  } else {
+
+    if ($item->{source_item}) {
+      # Set discount from source order item if not given.
+      $item->discount($item->{source_item}->discount);
+      $item->active_discount_source($item->{source_item}->active_discount_source);
+
+    } else {
+      # Set discount the best discount of price source
+      my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
+      my $discount     = $price_source->best_discount;
+      if ($discount) {
+        $item->discount($discount->discount);
+        $item->active_discount_source($discount->source);
+      } else {
+        $item->discount(0);
+        $item->active_discount_source($price_source->discount_from_source('')->source);
+      }
+    }
+  }
+}
+
+sub check_contact {
+  my ($self, $entry) = @_;
+
+  my $object = $entry->{object};
+
+  my $cp_cv_id = $object->customer_id || $object->vendor_id;
+  return 0 unless $cp_cv_id;
+
+  # Check whether or not contact ID is valid.
+  if ($object->cp_id && !$self->contacts_by->{'cp_cv_id+cp_id'}->{ $cp_cv_id . '+' . $object->cp_id }) {
+    push @{ $entry->{errors} }, $::locale->text('Error: Invalid contact');
+    return 0;
+  }
+
+  # Map name to ID if given.
+  if (!$object->cp_id && $entry->{raw_data}->{contact}) {
+    my $cp = $self->contacts_by->{'cp_cv_id+cp_name'}->{ $cp_cv_id . '+' . $entry->{raw_data}->{contact} };
+    if (!$cp) {
+      push @{ $entry->{errors} }, $::locale->text('Error: Invalid contact');
+      return 0;
+    }
+
+    $object->cp_id($cp->cp_id);
+  }
+
+  if ($object->cp_id) {
+    $entry->{info_data}->{contact} = $self->contacts_by->{'cp_cv_id+cp_id'}->{ $cp_cv_id . '+' . $object->cp_id }->cp_name;
+  }
+
+  return 1;
+}
+
+sub check_language {
+  my ($self, $entry) = @_;
+
+  my $object = $entry->{object};
+
+  # Check whether or not language ID is valid.
+  if ($object->language_id && !$self->languages_by->{id}->{ $object->language_id }) {
+    push @{ $entry->{errors} }, $::locale->text('Error: Invalid language');
+    return 0;
+  }
+
+  # Map name to ID if given.
+  if (!$object->language_id && $entry->{raw_data}->{language}) {
+    my $language = $self->languages_by->{description}->{  $entry->{raw_data}->{language} }
+                || $self->languages_by->{article_code}->{ $entry->{raw_data}->{language} };
+
+    if (!$language) {
+      push @{ $entry->{errors} }, $::locale->text('Error: Invalid language');
+      return 0;
+    }
+
+    $object->language_id($language->id);
+  }
+
+  if ($object->language_id) {
+    $entry->{info_data}->{language} = $self->languages_by->{id}->{ $object->language_id }->description;
+  }
+
+  return 1;
+}
+
+sub check_ct_shipto {
+  my ($self, $entry) = @_;
+
+  my $object = $entry->{object};
+
+  my $trans_id = $object->customer_id || $object->vendor_id;
+  return 0 unless $trans_id;
+
+  # Check whether or not shipto ID is valid.
+  if ($object->shipto_id && !$self->ct_shiptos_by->{'trans_id+shipto_id'}->{ $trans_id . '+' . $object->shipto_id }) {
+    push @{ $entry->{errors} }, $::locale->text('Error: Invalid shipto');
+    return 0;
+  }
+
+  return 1;
+}
+
+sub check_part {
+  my ($self, $entry) = @_;
+
+  my $object = $entry->{object};
+  my $is_ambiguous;
+
+  # Check whether or not part ID is valid.
+  if ($object->parts_id && !$self->parts_by->{id}->{ $object->parts_id }) {
+    push @{ $entry->{errors} }, $::locale->text('Error: Part not found');
+    return 0;
+  }
+
+  # Map number to ID if given.
+  if (!$object->parts_id && $entry->{raw_data}->{partnumber}) {
+    my $part = $self->parts_by->{partnumber}->{ trim($entry->{raw_data}->{partnumber}) };
+    if (!$part) {
+      push @{ $entry->{errors} }, $::locale->text('Error: Part not found');
+      return 0;
+    }
+
+    $object->parts_id($part->id);
+  }
+
+  # Map description to ID if given.
+  if (!$object->parts_id && $entry->{raw_data}->{description}) {
+    my $part = $self->parts_by->{description}->{ trim($entry->{raw_data}->{description}) };
+    if (!$part) {
+      push @{ $entry->{errors} }, $::locale->text('Error: Part not found');
+      return 0;
+    }
+
+    if ($self->part_counts_by->{description}->{ trim($entry->{raw_data}->{description}) } > 1) {
+      $is_ambiguous = 1;
+    } else {
+      $object->parts_id($part->id);
+    }
+  }
+
+  # Map ean to ID if given.
+  if (!$object->parts_id && $entry->{raw_data}->{ean}) {
+    my $part = $self->parts_by->{ean}->{ trim($entry->{raw_data}->{ean}) };
+    if (!$part) {
+      push @{ $entry->{errors} }, $::locale->text('Error: Part not found');
+      return 0;
+    }
+
+    if ($self->part_counts_by->{ean}->{ trim($entry->{raw_data}->{ean}) } > 1) {
+      $is_ambiguous = 1;
+    } else {
+      $object->parts_id($part->id);
+    }
+  }
+
+  if ($object->parts_id) {
+    $entry->{info_data}->{partnumber} = $self->parts_by->{id}->{ $object->parts_id }->partnumber;
+  } else {
+    if ($is_ambiguous) {
+      push @{ $entry->{errors} }, $::locale->text('Error: Part is ambiguous');
+    } else {
+      push @{ $entry->{errors} }, $::locale->text('Error: Part not found');
+    }
+    return 0;
+  }
+
+  return 1;
+}
+
+sub check_price_factor {
+  my ($self, $entry) = @_;
+
+  my $object = $entry->{object};
+
+  # Check whether or not price_factor ID is valid.
+  if ($object->price_factor_id && !$self->price_factors_by->{id}->{ $object->price_factor_id }) {
+    push @{ $entry->{errors} }, $::locale->text('Error: Invalid price factor');
+    return 0;
+  }
+
+  # Map description to ID if given.
+  if (!$object->price_factor_id && $entry->{raw_data}->{price_factor}) {
+    my $price_factor = $self->price_factors_by->{description}->{ $entry->{raw_data}->{price_factor} };
+    if (!$price_factor) {
+      push @{ $entry->{errors} }, $::locale->text('Error: Invalid price factor');
+      return 0;
+    }
+
+    $object->price_factor_id($price_factor->id);
+  }
+
+  return 1;
+}
+
+sub check_pricegroup {
+  my ($self, $entry) = @_;
+
+  my $object = $entry->{object};
+
+  # Check whether or not pricegroup ID is valid.
+  if ($object->pricegroup_id && !$self->pricegroups_by->{id}->{ $object->pricegroup_id }) {
+    push @{ $entry->{errors} }, $::locale->text('Error: Invalid price group');
+    return 0;
+  }
+
+  # Map pricegroup to ID if given.
+  if (!$object->pricegroup_id && $entry->{raw_data}->{pricegroup}) {
+    my $pricegroup = $self->pricegroups_by->{pricegroup}->{ $entry->{raw_data}->{pricegroup} };
+    if (!$pricegroup) {
+      push @{ $entry->{errors} }, $::locale->text('Error: Invalid price group');
+      return 0;
+    }
+
+    $object->pricegroup_id($pricegroup->id);
+  }
+
+  return 1;
+}
+
+sub check_warehouse {
+  my ($self, $entry) = @_;
+
+  my $object = $entry->{object};
+
+  # Check whether or not warehouse ID is valid.
+  if ($object->warehouse_id && !$self->warehouses_by->{id}->{ $object->warehouse_id }) {
+    push @{ $entry->{errors} }, $::locale->text('Error: Invalid warehouse');
+    return 0;
+  }
+
+  # Map description to ID if given.
+  if (!$object->warehouse_id && $entry->{raw_data}->{warehouse}) {
+    my $wh = $self->warehouses_by->{description}->{ $entry->{raw_data}->{warehouse} };
+    if (!$wh) {
+      push @{ $entry->{errors} }, $::locale->text('Error: Invalid warehouse');
+      return 0;
+    }
+
+    $object->warehouse_id($wh->id);
+  }
+
+  if ($object->warehouse_id) {
+    $entry->{info_data}->{warehouse} = $self->warehouses_by->{id}->{ $object->warehouse_id }->description;
+  } else {
+    push @{ $entry->{errors} }, $::locale->text('Error: Warehouse not found');
+    return 0;
+  }
+
+  return 1;
+}
+
+# Check bin for given warehouse, so check_warehouse must be called first.
+sub check_bin {
+  my ($self, $entry) = @_;
+
+  my $object = $entry->{object};
+
+  # Check whether or not bin ID is valid.
+  if ($object->bin_id && !$self->bins_by->{_wh_id_and_id_ident()}->{ _wh_id_and_id_maker($object->warehouse_id, $object->bin_id) }) {
+    push @{ $entry->{errors} }, $::locale->text('Error: Invalid bin');
+    return 0;
+  }
+
+  # Map description to ID if given.
+  if (!$object->bin_id && $entry->{raw_data}->{bin}) {
+    my $bin = $self->bins_by->{_wh_id_and_description_ident()}->{ _wh_id_and_description_maker($object->warehouse_id, $entry->{raw_data}->{bin}) };
+    if (!$bin) {
+      push @{ $entry->{errors} }, $::locale->text('Error: Invalid bin');
+      return 0;
+    }
+
+    $object->bin_id($bin->id);
+  }
+
+  if ($object->bin_id) {
+    $entry->{info_data}->{bin} = $self->bins_by->{_wh_id_and_id_ident()}->{ _wh_id_and_id_maker($object->warehouse_id, $object->bin_id) }->description;
+  } else {
+    push @{ $entry->{errors} }, $::locale->text('Error: Bin not found');
+    return 0;
+  }
+
+  return 1;
+}
+
+sub save_additions {
+  my ($self, $object) = @_;
+
+  # record links
+  my $orders = delete $object->{source_orders};
+
+  if (scalar(@$orders)) {
+
+    $_->link_to_record($object) for @$orders;
+
+    foreach my $item (@{ $object->items }) {
+      my $orderitem = delete $item->{source_item};
+      $orderitem->link_to_record($item) if $orderitem;
+    }
+  }
+
+  # delivery order for all positions created?
+  if (scalar(@$orders)) {
+    foreach my $order (@{ $orders }) {
+      my $all_deliverd;
+      foreach my $orderitem (@{ $order->items }) {
+        my $delivered_qty = 0;
+        foreach my $do_item (@{$orderitem->linked_records(to => 'DeliveryOrderItem')}) {
+          $delivered_qty += $do_item->unit_obj->convert_to($do_item->qty, $orderitem->unit_obj);
+        }
+        $all_deliverd = $orderitem->qty <= $delivered_qty;
+        last if !$all_deliverd;
+      }
+      $order->update_attributes(delivered => !!$all_deliverd);
+    }
+  }
+
+  # inventory (or use WH->transfer?)
+  foreach my $item (@{ $object->items }) {
+    foreach my $stock_info (@{ $item->delivery_order_stock_entries }) {
+      my $inventory  = delete $stock_info->{inventory_obj};
+      next if !$inventory;
+      my ($trans_id) = selectrow_query($::form, $object->db->dbh, qq|SELECT nextval('id')|);
+      $inventory->trans_id($trans_id);
+      $inventory->oe_id($object->id);
+      $inventory->delivery_order_items_stock_id($stock_info->id);
+      $inventory->save;
+    }
+  }
+}
+
+sub save_objects {
+  my ($self, %params) = @_;
+
+  # Collect orders without errors to save.
+  my $entries_to_save = [];
+  foreach my $entry (@{ $self->controller->data }) {
+    next if $entry->{raw_data}->{datatype} ne $self->_order_column;
+    next if @{ $entry->{errors} };
+
+    push @{ $entries_to_save }, $entry;
+  }
+
+  $self->SUPER::save_objects(data => $entries_to_save);
+}
+
+sub get_stocked_qty {
+  my ($self, $parts_id, $wh_id, $bin_id, $chargenumber, $bestbefore) = @_;
+
+  my $key = join '+', $parts_id, $wh_id, $bin_id, $chargenumber, $bestbefore;
+  return $self->{stocked_qty}->{$key} if exists $self->{stocked_qty}->{$key};
+
+  my $bestbefore_filter  = '';
+  my $bestbefore_val_cnt = 0;
+  if ($::instance_conf->get_show_bestbefore) {
+    $bestbefore_filter  = ($bestbefore) ? 'AND bestbefore = ?' : 'AND bestbefore IS NULL';
+    $bestbefore_val_cnt = ($bestbefore) ? 1                    : 0;
+  }
+
+  my $query = <<SQL;
+    SELECT sum(qty) FROM inventory
+      WHERE parts_id = ? AND warehouse_id = ? AND bin_id = ? AND chargenumber = ? $bestbefore_filter
+      GROUP BY warehouse_id, bin_id, chargenumber
+SQL
+
+  my @values = ($parts_id,
+                $wh_id,
+                $bin_id,
+                $chargenumber);
+  push @values, $bestbefore if $bestbefore_val_cnt;
+
+  my $dbh = $self->controller->data->[0]{object}->db->dbh;
+  my ($stocked_qty) = selectrow_query($::form, $dbh, $query, @values);
+
+  $self->{stocked_qty}->{$key} = $stocked_qty;
+  return $stocked_qty;
+}
+
+sub _wh_id_and_description_ident {
+  return 'wh_id+description';
+}
+
+sub _wh_id_and_description_maker {
+  return join '+', $_[0], $_[1]
+}
+
+sub _wh_id_and_id_ident {
+  return 'wh_id+id';
+}
+
+sub _wh_id_and_id_maker {
+  return join '+', $_[0], $_[1]
+}
+
+sub _transfer_type_dir_and_description_ident {
+  return 'dir+description';
+}
+
+sub _transfer_type_dir_and_description_maker {
+  return join '+', $_[0], $_[1]
+}
+
+sub _order_column {
+  $_[0]->settings->{'order_column'}
+}
+
+sub _item_column {
+  $_[0]->settings->{'item_column'}
+}
+
+sub _stock_column {
+  $_[0]->settings->{'stock_column'}
+}
+
+1;
index d16480e..7666903 100644 (file)
@@ -14,6 +14,8 @@ Mittelgroße neue Features:
  - Verarbeitung von ZUGFeRD 2.0 kompatiblen Eingangsrechnungen über
    Kreditorenbuchungsvorlagen
 
+ - CSV-Import für Lieferscheine
+
 Kleinere neue Features und Detailverbesserungen:
 
  - Suche nach Erzeugnissen über die dort verbauten Artikel
index fce1e0d..4b8df10 100755 (executable)
@@ -514,6 +514,7 @@ $self->{texts} = {
   'CSV import: bank transactions' => 'CSV Import: Bankbewegungen',
   'CSV import: contacts'        => 'CSV-Import: Ansprechpersonen',
   'CSV import: customers and vendors' => 'CSV-Import: Kunden und Lieferanten',
+  'CSV import: delivery orders' => 'CSV-Import: Lieferscheine',
   'CSV import: inventories'     => 'CSV-Import: Lagerbewegungen/-bestände',
   'CSV import: orders'          => 'CSV-Import: Aufträge',
   'CSV import: parts and services' => 'CSV-Import: Waren und Dienstleistungen',
@@ -1001,6 +1002,7 @@ $self->{texts} = {
   'Delivery terms'              => 'Lieferbedingungen',
   'Delivery terms (database ID)' => 'Lieferbedingungen (Datenbank-ID)',
   'Delivery terms (name)'       => 'Lieferbedingungen (Name)',
+  'DeliveryOrder'               => 'Lieferschein',
   'Denmark'                     => 'Dänemark',
   'Department'                  => 'Abteilung',
   'Department (database ID)'    => 'Abeilung (Datenbank-ID)',
@@ -1275,6 +1277,7 @@ $self->{texts} = {
   'Equity'                      => 'Passiva',
   'Erfolgsrechnung'             => 'Erfolgsrechnung',
   'Error'                       => 'Fehler',
+  'Error handling'              => 'Fehlerbehandlung',
   'Error in database control file \'%s\': %s' => 'Fehler in Datenbankupgradekontrolldatei \'%s\': %s',
   'Error in position #1: You must either assign no stock at all or the full quantity of #2 #3.' => 'Fehler in Position #1: Sie müssen einer Position entweder gar keinen Lagereingang oder die vollständige im Lieferschein vermerkte Menge von #2 #3 zuweisen.',
   'Error in position #1: You must either assign no transfer at all or the full quantity of #2 #3.' => 'Fehler in Position #1: Sie müssen einer Position entweder gar keinen Lagerausgang oder die vollständige im Lieferschein vermerkte Menge von #2 #3 zuweisen.',
@@ -1296,6 +1299,7 @@ $self->{texts} = {
   'Error: Customer/vendor is ambiguous' => 'Kunde/Lieferant ist mehrdeutig',
   'Error: Customer/vendor missing' => 'Fehler: Kunde/Lieferant fehlt',
   'Error: Customer/vendor not found' => 'Fehler: Kunde/Lieferant nicht gefunden',
+  'Error: Faulty position in this delivery order' => 'Fehler: Fehlerhafte Artikel-Position in diesem Lieferschein',
   'Error: Found local bank account number but local bank code doesn\'t match' => 'Fehler: Kontonummer wurde gefunden aber gespeicherte Bankleitzahl stimmt nicht überein',
   'Error: Gender (cp_gender) missing or invalid' => 'Fehler: Geschlecht (cp_gender) fehlt oder ungültig',
   'Error: Invalid bin'          => 'Fehler: Ungültiger Lagerplatz',
@@ -1322,11 +1326,17 @@ $self->{texts} = {
   'Error: Invalid warehouse'    => 'Fehler: Ungültiges Lager',
   'Error: Invalid warehouse id' => 'Ungültige Lager-ID',
   'Error: Invalid warehouse name #1' => 'Ungültiger Lagername \'#1\'',
+  'Error: More than one source order found' => 'Fehler: mehr als ein Quell-Auftrag gefunden',
   'Error: Name missing'         => 'Fehler: Name fehlt',
+  'Error: Not enough parts in stock' => 'Fehler: Nicht genügend Artikel eingelagert',
   'Error: Part is ambiguous'    => 'Artikel ist mehrdeutig',
   'Error: Part is obsolete'     => 'Fehler: Artikel ist ungültig',
   'Error: Part not found'       => 'Fehler: Artikel nicht gefunden',
   'Error: Quantity to transfer is zero.' => 'Fehler: Zu bewegende Menge ist Null.',
+  'Error: Source order not found' => 'Fehler: Quell-Auftrag nicht gefunden',
+  'Error: Stock problem'        => 'Fehler: Problem bei der Lagerbewegung',
+  'Error: Stocking out would result in stock underrun' => 'Auslagern würde zu einem negativen Lagerbestand führen',
+  'Error: Stocking out would result in stock underrun: #1' => 'Auslagern würde zu einem negativen Lagerbestand führen: #1',
   'Error: Transfer would result in a negative target quantity.' => 'Fehler: Lagerbewegung würde zu einer negativen Zielmenge führen.',
   'Error: Unit missing or invalid' => 'Fehler: Einheit fehlt oder ungültig',
   'Error: Warehouse not found'  => 'Fehler: Lager nicht gefunden',
@@ -1627,6 +1637,7 @@ $self->{texts} = {
   'If you want to delete such a dataset you have to edit the client(s) that are using the dataset in question and have them use another dataset.' => 'Wenn Sie eine solche Datenbank löschen möchten, dann müssen Sie zuerst den/die Mandanten auf eine andere Datenbank umstellen, die die zu löschende Datenbank benutzen.',
   'If you want to set up the authentication database yourself then log in to the administration panel. kivitendo will then create the database and tables for you.' => 'Wenn Sie die Authentifizierungs-Datenbank selber einrichten wollen, so melden Sie sich im Administrationsbereich an. kivitendo wird dann die Datenbank und die erforderlichen Tabellen für Sie anlegen.',
   'If your old bins match exactly Bins in the Warehouse CLICK on <b>AUTOMATICALLY MATCH BINS</b>.' => 'Falls die alte Lagerplatz-Beschreibung in Stammdaten genau mit einem Lagerplatz in einem vorhandenem Lager übereinstimmt, KLICK auf <b>LAGERPLÄTZE AUTOMATISCH ZUWEISEN</b>',
+  'Ignore faulty positions'     => 'Fehlerhafte Artikel-Positionen ignorieren',
   'Illegal characters have been removed from the following fields: #1' => 'Ungültige Zeichen wurden aus den folgenden Feldern entfernt: #1',
   'Illegal date'                => 'Ungültiges Datum',
   'Image'                       => 'Grafik',
@@ -1740,6 +1751,7 @@ $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!',
@@ -2199,6 +2211,7 @@ $self->{texts} = {
   'Order probability & expected billing date' => 'Auftragswahrscheinlichkeit & vorrauss. Abrechnungsdatum',
   'Order value periodicity'     => 'Auftragswert basiert auf Periodizität',
   'Order/Item row name'         => 'Name der Auftrag-/Positions-Zeilen',
+  'Order/Item/Stock row name'   => 'Name der Auftrag-/Positions-/Lager-Zeilen',
   'Order/RFQ Number'            => 'Belegnummer',
   'OrderItem'                   => 'Position',
   'Ordered'                     => 'Von Kunden bestellt',
@@ -3061,6 +3074,7 @@ $self->{texts} = {
   'Stock for part #1'           => 'Bestand für Artikel #1',
   'Stock levels'                => 'Lagerbestände',
   'Stock value'                 => 'Bestandswert',
+  'StockInfo'                   => 'Lagerinfo',
   'Stocked Qty'                 => 'Lagermenge',
   'Stocktaking'                 => 'Inventur',
   'Stocktaking History'         => 'Inventur Historie',
@@ -3924,6 +3938,7 @@ $self->{texts} = {
   'Warn before saving orders without a delivery date' => 'Warnung ausgeben, falls Aufträge kein Lieferdatum haben.',
   'Warning'                     => 'Warnung',
   'Warning! Loading a draft will discard unsaved data!' => 'Achtung! Beim Laden eines Entwurfs werden ungespeicherte Daten verworfen!',
+  'Warning: Faulty position ignored' => 'Warnung: Fehlerhafte Artikel-Position ignoriert',
   'Warning: One or more field value are not in valid DATEV format at:' => 'Warnung: Ein oder mehere Felder haben ungültige Feldwerte laut DATEV-Spezifikation bei:',
   'Warnings and errors'         => 'Warnungen und Fehler',
   'Watch status'                => 'Status',
index a373362..ac3dbed 100644 (file)
@@ -514,6 +514,7 @@ $self->{texts} = {
   'CSV import: bank transactions' => '',
   'CSV import: contacts'        => '',
   'CSV import: customers and vendors' => '',
+  'CSV import: delivery orders' => '',
   'CSV import: inventories'     => '',
   'CSV import: orders'          => '',
   'CSV import: parts and services' => '',
@@ -1001,6 +1002,7 @@ $self->{texts} = {
   'Delivery terms'              => '',
   'Delivery terms (database ID)' => '',
   'Delivery terms (name)'       => '',
+  'DeliveryOrder'               => '',
   'Denmark'                     => '',
   'Department'                  => '',
   'Department (database ID)'    => '',
@@ -1275,6 +1277,7 @@ $self->{texts} = {
   'Equity'                      => '',
   'Erfolgsrechnung'             => '',
   'Error'                       => '',
+  'Error handling'              => '',
   'Error in database control file \'%s\': %s' => '',
   'Error in position #1: You must either assign no stock at all or the full quantity of #2 #3.' => '',
   'Error in position #1: You must either assign no transfer at all or the full quantity of #2 #3.' => '',
@@ -1296,6 +1299,7 @@ $self->{texts} = {
   'Error: Customer/vendor is ambiguous' => '',
   'Error: Customer/vendor missing' => '',
   'Error: Customer/vendor not found' => '',
+  'Error: Faulty position in this delivery order' => '',
   'Error: Found local bank account number but local bank code doesn\'t match' => '',
   'Error: Gender (cp_gender) missing or invalid' => '',
   'Error: Invalid bin'          => '',
@@ -1322,11 +1326,17 @@ $self->{texts} = {
   'Error: Invalid warehouse'    => '',
   'Error: Invalid warehouse id' => '',
   'Error: Invalid warehouse name #1' => '',
+  'Error: More than one source order found' => '',
   'Error: Name missing'         => '',
+  'Error: Not enough parts in stock' => '',
   'Error: Part is ambiguous'    => '',
   'Error: Part is obsolete'     => '',
   'Error: Part not found'       => '',
   'Error: Quantity to transfer is zero.' => '',
+  'Error: Source order not found' => '',
+  'Error: Stock problem'        => '',
+  'Error: Stocking out would result in stock underrun' => '',
+  'Error: Stocking out would result in stock underrun: #1' => '',
   'Error: Transfer would result in a negative target quantity.' => '',
   'Error: Unit missing or invalid' => '',
   'Error: Warehouse not found'  => '',
@@ -1627,6 +1637,7 @@ $self->{texts} = {
   'If you want to delete such a dataset you have to edit the client(s) that are using the dataset in question and have them use another dataset.' => '',
   'If you want to set up the authentication database yourself then log in to the administration panel. kivitendo will then create the database and tables for you.' => '',
   'If your old bins match exactly Bins in the Warehouse CLICK on <b>AUTOMATICALLY MATCH BINS</b>.' => '',
+  'Ignore faulty positions'     => '',
   'Illegal characters have been removed from the following fields: #1' => '',
   'Illegal date'                => '',
   'Image'                       => '',
@@ -1740,6 +1751,7 @@ $self->{texts} = {
   'Invoices with payments cannot be canceled.' => '',
   'Invoices, Credit Notes & AR Transactions' => '',
   'Is Searchable'               => '',
+  'Is sales'                    => '',
   'Is this a summary account to record' => '',
   'It can be changed later but must be unique within the installation.' => '',
   'It is not allowed that a summary account occurs in a drop-down menu!' => '',
@@ -2199,6 +2211,7 @@ $self->{texts} = {
   'Order probability & expected billing date' => '',
   'Order value periodicity'     => '',
   'Order/Item row name'         => '',
+  'Order/Item/Stock row name'   => '',
   'Order/RFQ Number'            => '',
   'OrderItem'                   => '',
   'Ordered'                     => '',
@@ -3061,6 +3074,7 @@ $self->{texts} = {
   'Stock for part #1'           => '',
   'Stock levels'                => '',
   'Stock value'                 => '',
+  'StockInfo'                   => '',
   'Stocked Qty'                 => '',
   'Stocktaking'                 => '',
   'Stocktaking History'         => '',
@@ -3923,6 +3937,7 @@ $self->{texts} = {
   'Warn before saving orders without a delivery date' => '',
   'Warning'                     => '',
   'Warning! Loading a draft will discard unsaved data!' => '',
+  'Warning: Faulty position ignored' => '',
   'Warning: One or more field value are not in valid DATEV format at:' => '',
   'Warnings and errors'         => '',
   'Watch status'                => '',
index b56a7b9..f223a1d 100644 (file)
   params:
     action: CsvImport/new
     profile.type: orders
+- parent: system_import_csv
+  id: system_import_csv_delivery_orders
+  name: Delivery Orders
+  order: 720
+  params:
+    action: CsvImport/new
+    profile.type: delivery_orders
 - parent: system_import_csv
   id: system_import_csv_ar_transactions
   name: AR Transactions
diff --git a/templates/webpages/csv_import/_form_delivery_orders.html b/templates/webpages/csv_import/_form_delivery_orders.html
new file mode 100644 (file)
index 0000000..62cc4a0
--- /dev/null
@@ -0,0 +1,17 @@
+[% USE LxERP %]
+[% USE L %]
+<tr>
+ <th align="right">[%- LxERP.t8('Order/Item/Stock row name') %]:</th>
+ <td colspan="10">
+  [% L.input_tag('settings.order_column', SELF.profile.get('order_column'), size => "10") %]
+  [% L.input_tag('settings.item_column',  SELF.profile.get('item_column'),  size => "10") %]
+  [% L.input_tag('settings.stock_column', SELF.profile.get('stock_column'), size => "10") %]
+ </td>
+<tr>
+ <th align="right">[%- LxERP.t8('Error handling') %]:</th>
+ <td colspan="10">
+  [% L.checkbox_tag('settings.ignore_faulty_positions',
+                    label   => LxERP.t8('Ignore faulty positions'),
+                    checked => SELF.profile.get('ignore_faulty_positions')) %]
+ </td>
+</tr>
index cb07c03..aecbf1d 100644 (file)
     [%- LxERP.t8('One of the columns "qty" or "target_qty" must be given. If "target_qty" is given, the quantity to transfer for each transfer will be calculate, so that the quantity for this part, warehouse and bin will result in the given "target_qty" after each transfer.') %]
    </p>
 
-[%- ELSIF SELF.type == 'orders' OR SELF.type == 'ar_transactions' %]
+[%- ELSIF SELF.type == 'orders' OR SELF.type == 'delivery_orders' OR SELF.type == 'ar_transactions' %]
    <p>
     [1]:
     [% LxERP.t8('The column "datatype" must be present and must be at the same position / column in each data set. The values must be the row names (see settings) for order and item data respectively.') %]
    </p>
-   <p>
-    [2]:
-    [%- LxERP.t8('Amount and net amount are calculated by kivitendo. "verify_amount" and "verify_netamount" can be used for sanity checks.') %]<br>
-    [%- LxERP.t8('If amounts differ more than "Maximal amount difference" (see settings), this item is marked as invalid.') %]<br>
-   </p>
+   [%- IF SELF.type == 'orders' OR SELF.type == 'ar_transactions' %]
+    <p>
+     [2]:
+     [%- LxERP.t8('Amount and net amount are calculated by kivitendo. "verify_amount" and "verify_netamount" can be used for sanity checks.') %]<br>
+     [%- LxERP.t8('If amounts differ more than "Maximal amount difference" (see settings), this item is marked as invalid.') %]<br>
+    </p>
+   [%- END %]
 [%- END %][%# IF SELF.type == … %]
 
    <p>
  [%- INCLUDE 'csv_import/_form_inventories.html' %]
 [%- ELSIF SELF.type == 'orders' %]
  [%- INCLUDE 'csv_import/_form_orders.html' %]
+[%- ELSIF SELF.type == 'delivery_orders' %]
+ [%- INCLUDE 'csv_import/_form_delivery_orders.html' %]
 [%- ELSIF SELF.type == 'ar_transactions' %]
  [%- INCLUDE 'csv_import/_form_artransactions.html' %]
 [%- ELSIF SELF.type == 'bank_transactions' %]