CSV-Import Artikel: Einige Erweiterungen
authorMartin Helmling martin.helmling@octosoft.eu <martin.helmling@octosoft.eu>
Thu, 25 Aug 2016 10:37:28 +0000 (12:37 +0200)
committerMartin Helmling martin.helmling@octosoft.eu <martin.helmling@octosoft.eu>
Mon, 29 Aug 2016 12:41:35 +0000 (14:41 +0200)
CSV-Import von Artikel hat nun für existierende Artikel folgende Optionen:

     1. Eigenschaften von existierenden Einträgen aktualisieren
     2. Eigenschaften von existierenden Artikeln aktualisieren / Nicht vorhandene überspringen
     3. Preise von vorhandenen Artikeln aktualisieren
     4. Preise von vorhandenen Artikel aktualisieren / Nicht vorhandene überspringen
     5. Mit neuer Artikelnummer einfügen
     6. Eintrag überspringen

    Zusätzlich können nun Spalten "Lager","Lagerort" als Name oder ID eingelesen werden,
    sowie Übersetzungen z.B. als 'description_EN' oder 'description_IT'.
    Auch cvars können als 'cvars_<name>' importiert werden.
    Ebenfalls sind weitere Bemerkungen an den einzelnen Importzeilen eingebaut.

SL/Controller/CsvImport/Part.pm
doc/changelog
locale/de/all
t/controllers/csvimport/parts.t [new file with mode: 0644]
templates/webpages/csv_import/_form_parts.html

index b5e9185..6c94ef5 100644 (file)
@@ -24,6 +24,7 @@ use Rose::Object::MakeMethods::Generic
 (
  scalar                  => [ qw(table makemodel_columns) ],
  'scalar --get_set_init' => [ qw(bg_by settings parts_by price_factors_by units_by partsgroups_by
+                                 warehouses_by bins_by
                                  translation_columns all_pricegroups) ],
 );
 
@@ -78,6 +79,21 @@ sub init_units_by {
   return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $all_units } } ) } qw(name) };
 }
 
+sub init_bins_by {
+  my ($self) = @_;
+
+  my $all_bins = SL::DB::Manager::Bin->get_all;
+  return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $all_bins } } ) } qw(id description) };
+}
+
+sub init_warehouses_by {
+  my ($self) = @_;
+
+  my $all_warehouses = SL::DB::Manager::Warehouse->get_all;
+  return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $all_warehouses } } ) } qw(id description) };
+}
+
+
 sub init_parts_by {
   my ($self) = @_;
 
@@ -145,6 +161,7 @@ sub check_objects {
     $self->check_price_factor($entry);
     $self->check_payment($entry);
     $self->check_partsgroup($entry);
+    $self->check_warehouse_and_bin($entry);
     $self->handle_pricegroups($entry);
     $self->check_existing($entry) unless @{ $entry->{errors} };
     $self->handle_prices($entry) if $self->settings->{sellprice_adjustment};
@@ -156,10 +173,9 @@ sub check_objects {
   } continue {
     $i++;
   }
-
   $self->add_columns(qw(type)) if $self->settings->{parts_type} eq 'mixed';
   $self->add_columns(qw(buchungsgruppen_id unit));
-  $self->add_columns(map { "${_}_id" } grep { exists $self->controller->data->[0]->{raw_data}->{$_} } qw (price_factor payment partsgroup));
+  $self->add_columns(map { "${_}_id" } grep { exists $self->controller->data->[0]->{raw_data}->{$_} } qw (price_factor payment partsgroup warehouse bin));
   $self->add_columns(qw(shop)) if $self->settings->{shoparticle_if_missing};
   $self->add_cvar_raw_data_columns;
   map { $self->add_raw_data_columns("pricegroup_${_}") if exists $self->controller->data->[0]->{raw_data}->{"pricegroup_$_"} } (1..scalar(@{ $self->all_pricegroups }));
@@ -198,7 +214,10 @@ sub check_buchungsgruppe {
   $default_id    = undef unless $self->bg_by->{id}->{ $default_id };
 
   # 1. Use default ID if enforced.
-  $object->buchungsgruppen_id($default_id) if $default_id && ($self->settings->{apply_buchungsgruppe} eq 'all');
+  if ($default_id && ($self->settings->{apply_buchungsgruppe} eq 'all')) {
+    $object->buchungsgruppen_id($default_id);
+    push @{ $entry->{information} }, $::locale->text('Use default booking group because setting is \'all\'');
+  }
 
   # 2. Use supplied ID if valid
   $object->buchungsgruppen_id(undef) if $object->buchungsgruppen_id && !$self->bg_by->{id}->{ $object->buchungsgruppen_id };
@@ -210,11 +229,28 @@ sub check_buchungsgruppe {
   }
 
   # 4. Use default ID if not valid.
-  $object->buchungsgruppen_id($default_id) if !$object->buchungsgruppen_id && $default_id && ($self->settings->{apply_buchungsgruppe} eq 'missing');
-
+  if (!$object->buchungsgruppen_id && $default_id && ($self->settings->{apply_buchungsgruppe} eq 'missing')) {
+    $object->buchungsgruppen_id($default_id) ;
+    $entry->{buch_information} = $::locale->text('Use default booking group because wanted is missing');
+  }
   return 1 if $object->buchungsgruppen_id;
+  $entry->{buch_error} =  $::locale->text('Error: booking group missing or invalid');
+  return 0;
+}
 
-  push @{ $entry->{errors} }, $::locale->text('Error: booking group missing or invalid');
+sub _part_is_used {
+  my ($self, $part) = @_;
+
+  my $query =
+      qq|SELECT COUNT(parts_id) FROM invoice where parts_id = ?
+         UNION
+         SELECT COUNT(parts_id) FROM assembly where parts_id = ?
+         UNION
+         SELECT COUNT(parts_id) FROM orderitems where parts_id = ?
+        |;
+  foreach my $ref (selectall_hashref_query($::form, $part->db->dbh, $query, $part->id, $part->id, $part->id)) {
+    return 1 if $ref->{count} != 0;
+  }
   return 0;
 }
 
@@ -222,34 +258,129 @@ sub check_existing {
   my ($self, $entry) = @_;
 
   my $object = $entry->{object};
+  my $raw = $entry->{raw_data};
 
   if ($object->partnumber && $self->parts_by->{partnumber}{$object->partnumber}) {
-    $entry->{part} = SL::DB::Manager::Part->find_by(partnumber => $object->partnumber);
+    $entry->{part} = SL::DB::Manager::Part->get_all( query => [ partnumber => $object->partnumber ], limit => 1,
+      with_objects => [ 'translations', 'custom_variables' ]
+    ) -> [0];
+    if ( !$entry->{part} ) {
+        $entry->{part} = SL::DB::Manager::Part->get_all( query => [ partnumber => $object->partnumber ], limit => 1,
+          with_objects => [ 'translations' ]
+        ) -> [0];
+    }
   }
 
   if ($entry->{part}) {
-    if ($self->settings->{article_number_policy} eq 'update_prices') {
-      if ($self->settings->{parts_type} eq 'mixed' && $entry->{part}->type ne $object->type) {
-        push(@{$entry->{errors}}, $::locale->text('Skipping due to existing entry in database with different type'));
-      } else {
-        map { $entry->{part}->$_( $object->$_ ) if defined $object->$_ } qw(sellprice listprice lastcost);
+    if ($entry->{part}->type ne $object->type ) {
+      push(@{$entry->{errors}}, $::locale->text('Skipping due to existing entry in database with different type'));
+      return;
+    }
+    if ( $entry->{part}->unit != $object->unit || $entry->{part}->inventory_accno_id != $object->inventory_accno_id ) {
+      if ( $entry->{part}->onhand != 0 || $self->_part_is_used($entry->{part})) {
+        push(@{$entry->{errors}}, $::locale->text('Skipping due to existing entry with different unit or inventory_accno_id'));
+        return;
+      }
+    }
+  }
 
+  if ($self->settings->{article_number_policy} eq 'update_prices_sn' || $self->settings->{article_number_policy} eq 'update_parts_sn') {
+    if (!$entry->{part}) {
+      push(@{$entry->{errors}}, $::locale->text('Skipping non-existent article'));
+      return;
+    }
+  }
+
+  ## checking also doubles in csv !!
+  foreach my $csventry (@{ $self->controller->data }) {
+    if ( $entry != $csventry && $object->partnumber eq $csventry->{object}->partnumber ) {
+      if ( $csventry->{doublechecked} ) {
+        push(@{$entry->{errors}}, $::locale->text('Skipping due to same partnumber in csv file'));
+        return;
+      }
+    }
+  }
+  $entry->{doublechecked} = 1;
+
+  if ($entry->{part}) {
+    if ($self->settings->{article_number_policy} eq 'update_prices' || $self->settings->{article_number_policy} eq 'update_prices_sn') {
+      map { $entry->{part}->$_( $object->$_ ) if defined $object->$_ } qw(sellprice listprice lastcost);
+
+      # merge prices
+      my %prices_by_pricegroup_id = map { $_->pricegroup->id => $_ } $entry->{part}->prices, $object->prices;
+      $entry->{part}->prices(grep { $_ } map { $prices_by_pricegroup_id{$_->id} } @{ $self->all_pricegroups });
+
+      push @{ $entry->{information} }, $::locale->text('Updating prices of existing entry in database');
+      $entry->{object_to_save} = $entry->{part};
+    } elsif ( $self->settings->{article_number_policy} eq 'update_parts' || $self->settings->{article_number_policy} eq 'update_parts_sn') {
+
+      # Update parts table
+      # copy only the data which is not explicit copied by  "methods"
+
+      map { $entry->{part}->$_( $object->$_ ) if defined $object->$_ }  qw(description notes weight ean rop image
+                                                                           drawing ve gv
+                                                                           unit
+                                                                           has_sernumber not_discountable obsolete
+                                                                           payment_id
+                                                                           sellprice listprice lastcost);
+
+      if (defined $raw->{"sellprice"} || defined $raw->{"listprice"} || defined $raw->{"lastcost"}) {
         # merge prices
         my %prices_by_pricegroup_id = map { $_->pricegroup->id => $_ } $entry->{part}->prices, $object->prices;
         $entry->{part}->prices(grep { $_ } map { $prices_by_pricegroup_id{$_->id} } @{ $self->all_pricegroups });
+      }
 
-        push @{ $entry->{information} }, $::locale->text('Updating prices of existing entry in database');
-        $entry->{object_to_save} = $entry->{part};
+      # Update translation
+      my @translations;
+      push @translations, $entry->{part}->translations;
+      foreach my $language (@{ $self->all_languages }) {
+        my $desc;
+        $desc = $raw->{"description_". $language->article_code}  if defined $raw->{"description_". $language->article_code};
+        my $notes;
+        $notes = $raw->{"notes_". $language->article_code}  if defined $raw->{"notes_". $language->article_code};
+        next unless $desc || $notes;
+
+        push @translations, SL::DB::Translation->new(language_id     => $language->id,
+                                                     translation     => $desc,
+                                                     longdescription => $notes);
       }
-    } elsif ( $self->settings->{article_number_policy} eq 'skip' ) {
-      push(@{$entry->{errors}}, $::locale->text('Skipping due to existing entry in database'));
+      $entry->{part}->translations(\@translations) if @translations;
+
+      # Update cvars
+      my %type_to_column = ( text      => 'text_value',
+                             textfield => 'text_value',
+                             select    => 'text_value',
+                             date      => 'timestamp_value_as_date',
+                             timestamp => 'timestamp_value_as_date',
+                             number    => 'number_value_as_number',
+                             bool      => 'bool_value' );
+      my @cvars;
+      push @cvars, $entry->{part}->custom_variables;
+      foreach my $config (@{ $self->all_cvar_configs }) {
+        next unless exists $raw->{ "cvar_" . $config->name };
+        my $value  = $raw->{ "cvar_" . $config->name };
+        my $column = $type_to_column{ $config->type } || die "Program logic error: unknown custom variable storage type";
+        push @cvars, SL::DB::CustomVariable->new(config_id => $config->id, $column => $value, sub_module => '');
+      }
+      $entry->{part}->custom_variables(\@cvars) if @cvars;
+
+      # save Part Update
+      push @{ $entry->{information} }, $::locale->text('Updating data of existing entry in database');
+
+      $entry->{object_to_save} = $entry->{part};
+      # copy all other data via "methods"
+      my $methods        = $self->controller->headers->{methods};
+      $entry->{object_to_save}->$_( $entry->{object}->$_ ) for @{ $methods }, keys %{ $self->clone_methods };
 
+    } elsif ( $self->settings->{article_number_policy} eq 'skip' ) {
+      push(@{$entry->{errors}}, $::locale->text('Skipping due to existing entry in database')) if ( $entry->{part} );
     } else {
-      $object->partnumber('####');
-      push(@{$entry->{errors}}, $::locale->text('Skipping, for assemblies are not importable (yet)')) if $object->type eq 'assembly';
+      #$object->partnumber('####');
     }
   } else {
-    push(@{$entry->{errors}}, $::locale->text('Skipping, for assemblies are not importable (yet)')) if $object->type eq 'assembly';
+    # set error or info from buch if part not exists
+    push @{ $entry->{information} }, $entry->{buch_information} if $entry->{buch_information};
+    push @{ $entry->{errors} }, $entry->{buch_error} if $entry->{buch_error};
   }
 }
 
@@ -275,39 +406,47 @@ sub handle_shoparticle {
 sub check_type {
   my ($self, $entry) = @_;
 
-  my $bg = $self->bg_by->{id}->{ $entry->{object}->buchungsgruppen_id };
-  $bg  ||= SL::DB::Buchungsgruppe->new(inventory_accno_id => 1); # does this case ever occur?
-
   my $type = $self->settings->{parts_type};
-  if ($type eq 'mixed') {
+
+  if ($type eq 'mixed' && $entry->{raw_data}->{type}) {
     $type = $entry->{raw_data}->{type} =~ m/^p/i ? 'part'
           : $entry->{raw_data}->{type} =~ m/^s/i ? 'service'
           : $entry->{raw_data}->{type} =~ m/^a/i ? 'assembly'
           :                                        undef;
   }
 
-  $entry->{object}->assembly($type eq 'assembly');
-
   # when saving income_accno_id or expense_accno_id use ids from the selected
   # $bg according to the default tax_zone (the one with the highest sort
   # order).  Alternatively one could use the ids from defaults, but they might
   # not all be set.
+  # Only use existing bg
 
-  $entry->{object}->income_accno_id( $bg->income_accno_id( SL::DB::Manager::TaxZone->get_default->id ) );
+  my $bg = $self->bg_by->{id}->{ $entry->{object}->buchungsgruppen_id };
 
-  if ($type eq 'part' || $type eq 'service') {
-    $entry->{object}->expense_accno_id( $bg->expense_accno_id( SL::DB::Manager::TaxZone->get_default->id ) );
-  }
+  # if not set there is an error occurred in check_buchungsgruppe()
+  # but if the part exists the new values for accno are ignored
 
-  if ($type eq 'part') {
-    $entry->{object}->inventory_accno_id( $bg->inventory_accno_id );
-  }
+  if ( $bg ) {
+    $entry->{object}->income_accno_id( $bg->income_accno_id( SL::DB::Manager::TaxZone->get_default->id ) );
+    $self->clone_methods->{income_accno_id} = 1;
 
-  if (none { $_ eq $type } qw(part service assembly)) {
-    push @{ $entry->{errors} }, $::locale->text('Error: Invalid part type');
-    return 0;
+    if ($type eq 'part' || $type eq 'service') {
+      $entry->{object}->expense_accno_id( $bg->expense_accno_id( SL::DB::Manager::TaxZone->get_default->id ) );
+      $self->clone_methods->{expense_accno_id} = 1;
+    }
   }
 
+  if ($type eq 'part') {
+    if ( $bg ) {
+      $entry->{object}->inventory_accno_id( $bg->inventory_accno_id );
+    }
+    else {
+      #use an existent bg
+      $entry->{object}->inventory_accno_id( SL::DB::Manager::Buchungsgruppe->get_first->id );
+    }
+  } elsif ($type eq 'assembly') {
+      $entry->{object}->assembly(1);
+  }
   return 1;
 }
 
@@ -332,8 +471,62 @@ sub check_price_factor {
     }
 
     $object->price_factor_id($pf->id);
+    $self->clone_methods->{price_factor_id} = 1;
+  }
+
+  return 1;
+}
+
+sub check_warehouse_and_bin {
+  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 id');
+    return 0;
+  }
+  # Map name 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 name #1',$entry->{raw_data}->{warehouse});
+      return 0;
+    }
+
+    $object->warehouse_id($wh->id);
+  }
+  $self->clone_methods->{warehouse_id} = 1;
+
+  # Check whether or not bin id is valid.
+  if ($object->bin_id && !$self->bins_by->{id}->{ $object->bin_id }) {
+    push @{ $entry->{errors} }, $::locale->text('Error: Invalid bin id');
+    return 0;
   }
+  # Map name to ID if given.
+  if ($object->warehouse_id && !$object->bin_id && $entry->{raw_data}->{bin}) {
+    my $bin = $self->bins_by->{description}->{ $entry->{raw_data}->{bin} };
+
+    if (!$bin) {
+      push @{ $entry->{errors} }, $::locale->text('Error: Invalid bin name #1',$entry->{raw_data}->{bin});
+      return 0;
+    }
 
+    $object->bin_id($bin->id);
+  }
+  $self->clone_methods->{bin_id} = 1;
+
+  if ($object->warehouse_id && $object->bin_id ) {
+    my $bin = $self->bins_by->{id}->{ $object->bin_id };
+    if ( $bin->warehouse_id != $object->warehouse_id ) {
+      push @{ $entry->{errors} }, $::locale->text('Error: Bin #1 is not from warehouse #2',
+                                                  $self->bins_by->{id}->{$object->bin_id}->description,
+                                                  $self->warehouses_by->{id}->{ $object->warehouse_id }->description);
+      return 0;
+    }
+  }
   return 1;
 }
 
@@ -359,6 +552,8 @@ sub check_partsgroup {
 
     $object->partsgroup_id($pg->id);
   }
+  # register payment_id for method copying later
+  $self->clone_methods->{partsgroup_id} = 1;
 
   return 1;
 }
@@ -480,7 +675,7 @@ sub init_profile {
   my ($self) = @_;
 
   my $profile = $self->SUPER::init_profile;
-  delete @{$profile}{qw(assembly bom expense_accno_id income_accno_id inventory_accno_id makemodel priceupdate stockable type)};
+  delete @{$profile}{qw(alternate assembly bom expense_accno_id income_accno_id inventory_accno_id makemodel priceupdate stockable type)};
 
   $profile->{"pricegroup_$_"} = '' for 1 .. scalar @{ $_[0]->all_pricegroups };
 
@@ -505,9 +700,11 @@ sub setup_displayable_columns {
   $self->SUPER::setup_displayable_columns;
   $self->add_cvar_columns_to_displayable_columns;
 
-  $self->add_displayable_columns({ name => 'bin',                description => $::locale->text('Bin')                                                  },
-                                 { name => 'buchungsgruppen_id', description => $::locale->text('Booking group (database ID)')                          },
-                                 { name => 'buchungsgruppe',     description => $::locale->text('Booking group (name)')                                 },
+  $self->add_displayable_columns({ name => 'assembly',           description => $::locale->text('assembly')                                             },
+                                 { name => 'bin_id',             description => $::locale->text('Bin (database ID)')                                    },
+                                 { name => 'bin',                description => $::locale->text('Bin (name)')                                           },
+                                 { name => 'buchungsgruppen_id', description => $::locale->text('Booking group (database ID)')                         },
+                                 { name => 'buchungsgruppe',     description => $::locale->text('Booking group (name)')                                },
                                  { name => 'description',        description => $::locale->text('Description')                                          },
                                  { name => 'drawing',            description => $::locale->text('Drawing')                                              },
                                  { name => 'ean',                description => $::locale->text('EAN')                                                  },
@@ -515,6 +712,7 @@ sub setup_displayable_columns {
                                  { name => 'gv',                 description => $::locale->text('Business Volume')                                      },
                                  { name => 'has_sernumber',      description => $::locale->text('Has serial number')                                    },
                                  { name => 'image',              description => $::locale->text('Image')                                                },
+                                 { name => 'inventory_accno_id', description => $::locale->text('part')                                                 },
                                  { name => 'lastcost',           description => $::locale->text('Last Cost')                                            },
                                  { name => 'listprice',          description => $::locale->text('List Price')                                           },
                                  { name => 'make_X',             description => $::locale->text('Make (vendor\'s database ID, number or name; with X being a number)') . ' [1]' },
@@ -534,10 +732,12 @@ sub setup_displayable_columns {
                                  { name => 'price_factor',       description => $::locale->text('Price factor (name)')                                  },
                                  { name => 'rop',                description => $::locale->text('ROP')                                                  },
                                  { name => 'sellprice',          description => $::locale->text('Sellprice')                                            },
-                                 { name => 'shop',               description => $::locale->text('Shop article')                                         },
+                                 { name => 'shop',               description => $::locale->text('Shop article')                                          },
                                  { name => 'type',               description => $::locale->text('Article type')  . ' [3]'                             },
                                  { name => 'unit',               description => $::locale->text('Unit (if missing or empty default unit will be used)') },
                                  { name => 've',                 description => $::locale->text('Verrechnungseinheit')                                  },
+                                 { name => 'warehouse_id',       description => $::locale->text('Warehouse (database ID)')                              },
+                                 { name => 'warehouse',          description => $::locale->text('Warehouse (name)')                              },
                                  { name => 'weight',             description => $::locale->text('Weight')                                               },
                                 );
 
index 77e9afa..fa0caef 100644 (file)
@@ -8,6 +8,18 @@ kleinere neue Features und Detailverbesserungen:
 
   - Für UStVA Voranmeldung über Elster gibt es die Anbindung über Geierlein (Installation/Config siehe Commit)
   
+  - CSV-Import von Artikel hat nun für existierende Artikel folgende Optionen:
+     1. Eigenschaften von existierenden Einträgen aktualisieren
+     2. Eigenschaften von existierenden Artikeln aktualisieren / Nicht vorhandene überspringen
+     3. Preise von vorhandenen Artikeln aktualisieren
+     4. Preise von vorhandenen Artikel aktualisieren / Nicht vorhandene überspringen
+     5. Mit neuer Artikelnummer einfügen
+     6. Eintrag überspringen
+    Zusätzlich können nun Spalten "Lager","Lagerort" als Name oder ID eingelesen werden,
+    sowie Übersetzungen z.B. als 'description_EN' oder 'description_IT'.
+    Auch cvars können als 'cvars_<name>' importiert werden.
+    Ebenfalls sind zusätzliche Bemerkungen an den einzelnen Importzeilen eingebaut.
   - In der Lager-Mandantenkonfig gibt es das Feature "Zum Fertigen Standardlager des Bestandteils verwenden".
     Statt das Ziellager des Erzeugnisses zu Verwenden, wird nun zur Prüfung der Fertigung das
     Standardlager der einzelnen Bestandteile verwendet.
index e05ea08..53b2f62 100755 (executable)
@@ -408,6 +408,7 @@ $self->{texts} = {
   'Billing/shipping address (zipcode)' => 'Rechnungs-/Lieferadresse (PLZ)',
   'Bin'                         => 'Lagerplatz',
   'Bin (database ID)'           => 'Lagerplatz (Datenbank-ID)',
+  'Bin (name)'                  => 'Lagerplatz (Name)',
   'Bin From'                    => 'Quelllagerplatz',
   'Bin List'                    => 'Lagerliste',
   'Bin To'                      => 'Ziellagerplatz',
@@ -427,8 +428,8 @@ $self->{texts} = {
   'Booking group #1 needs a valid expense account' => 'Buchungsgruppe #1 braucht ein gültiges Aufwandskonto',
   'Booking group #1 needs a valid income account' => 'Buchungsgruppe #1 braucht ein gültiges Erfolgskonto',
   'Booking group #1 needs a valid inventory account' => 'Buchungsgruppe #1 braucht ein gültiges Warenbestandskonto',
-  'Booking group (database ID)' => 'Buchungsgruppe (Datenbank-ID)',
-  'Booking group (name)'        => 'Buchungsgruppe (Name)',
+  'Booking group (database ID)' => 'Buchungsgruppe (database ID)',
+  'Booking group (name)'        => 'Buchungsgruppe (name)',
   'Booking groups'              => 'Buchungsgruppen',
   'Books are open'              => 'Die Bücher sind geöffnet.',
   'Books closed up to'          => 'Bücher abgeschlossen bis zum',
@@ -1151,12 +1152,15 @@ $self->{texts} = {
   'Error: A negative target quantity is not allowed.' => 'Fehler: Eine negative Zielmenge ist nicht erlaubt.',
   'Error: A quantity and a target quantity could not be given both.' => 'Fehler: Menge und Zielmenge können nicht beide angegeben werden.',
   'Error: A quantity or a target quantity must be given.' => 'Fehler: Menge oder Zielmenge muss angegeben werden.',
+  'Error: Bin #1 is not from warehouse #2' => 'Lager \'#2\' hat keinen Lagerplatz \'#1\'',
   'Error: Bin not found'        => 'Fehler: Lagerplatz nicht gefunden',
   'Error: Customer/vendor missing' => 'Fehler: Kunde/Lieferant fehlt',
   'Error: Customer/vendor not found' => 'Fehler: Kunde/Lieferant nicht gefunden',
   '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',
+  'Error: Invalid bin id'       => 'Ungültige Lagerplatz-ID',
+  'Error: Invalid bin name #1'  => 'Ungültiger Lagerplatz \'#1\'',
   'Error: Invalid business'     => 'Fehler: Kunden-/Lieferantentyp ungültig',
   'Error: Invalid contact'      => 'Fehler: Ansprechperson ungültig',
   'Error: Invalid currency'     => 'Fehler: ungültige Währung',
@@ -1165,7 +1169,6 @@ $self->{texts} = {
   'Error: Invalid language'     => 'Fehler: Sprache ungültig',
   'Error: Invalid order for this order item' => 'Fehler: Auftrag für diese Position ungültig',
   'Error: Invalid part'         => 'Fehler: Artikel ungültig',
-  'Error: Invalid part type'    => 'Fehler: Artikeltyp ungültig',
   'Error: Invalid parts group'  => 'Fehler: Warengruppe ungültig',
   'Error: Invalid payment terms' => 'Fehler: Zahlungsbedingungen ungültig',
   'Error: Invalid price factor' => 'Fehler: Preisfaktor ungültig',
@@ -1177,6 +1180,8 @@ $self->{texts} = {
   'Error: Invalid unit'         => 'Fehler: Einheit ungültig',
   'Error: Invalid vendor in column make_#1' => 'Fehler: Lieferant ungültig in Spalte make_#1',
   '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: Name missing'         => 'Fehler: Name fehlt',
   'Error: Part not found'       => 'Fehler: Artikel nicht gefunden',
   'Error: Quantity to transfer is zero.' => 'Fehler: Zu bewegende Menge ist Null.',
@@ -2578,7 +2583,9 @@ $self->{texts} = {
   'Skipping due to existing bank transaction in database' => 'Wegen schon existierender Bankbewegung in Datenbank übersprungen',
   'Skipping due to existing entry in database' => 'Wegen existierendem Eintrag mit selber Nummer übersprungen',
   'Skipping due to existing entry in database with different type' => 'Wegen existierendem Eintrag von unterschiedlichem Artikeltyp übersprungen',
-  'Skipping, for assemblies are not importable (yet)' => 'Übersprungen, da Erzeugnisse (noch) nicht importiert werden können',
+  'Skipping due to existing entry with different unit or inventory_accno_id' => 'Wegen existierendem und verwendetem Eintrag von unterschiedlicher Einheit oder Buchungsgruppe übersprungen',
+  'Skipping due to same partnumber in csv file' => 'Eintrag in Datei mit doppelter Artikelnummer wird übersprungen',
+  'Skipping non-existent article' => 'Überspringe nicht vorhandenen Artikel',
   'Skonto'                      => 'Skonto',
   'Skonto Terms'                => 'Zahlungsziel Skonto',
   'Skonto amount'               => 'Skontobetrag',
@@ -3241,13 +3248,16 @@ $self->{texts} = {
   'Update SKR04: new tax account 3804 (19%)' => 'Update SKR04: neues Steuerkonto 3804 (19%) für innergemeinschaftlichen Erwerb',
   'Update prices'               => 'Preise aktualisieren',
   'Update prices of existing entries' => 'Preise von vorhandenen Artikeln aktualisieren',
+  'Update prices of existing entries / skip non-existent' => 'Preise von vorhandenen Artikel aktualisieren / Nicht vorhandene überspringen',
   'Update properties of existing entries' => 'Eigenschaften von existierenden Einträgen aktualisieren',
+  'Update properties of existing entries / skip non-existent' => 'Eigenschaften von existierenden Artikeln aktualisieren / Nicht vorhandene überspringen',
   'Update quotation/order'      => 'Auftrag/Angebot aktualisieren',
   'Update sales order #1'       => 'Kundenauftrag #1 aktualisieren',
   'Update sales quotation #1'   => 'Angebot #1 aktualisieren',
   'Update this draft.'          => 'Aktuellen Entwurf speichern',
   'Update with section'         => 'Mit Abschnitt aktualisieren',
   'Updated'                     => 'Erneuert am',
+  'Updating data of existing entry in database' => 'Aktualisierung von vorhandenen Datenbankdaten',
   'Updating existing entry in database' => 'Existierenden Eintrag in Datenbank aktualisieren',
   'Updating items with additional parts' => 'Positionen für zusätzliche Artikel aktualisieren',
   'Updating items with sections' => 'Positionen für Abschnitte aktualisieren',
@@ -3262,6 +3272,8 @@ $self->{texts} = {
   'Use Income'                  => 'GUV und BWA verwenden',
   'Use UStVA'                   => 'UStVA verwenden',
   'Use WebDAV Repository'       => 'WebDAV-Ablage verwenden',
+  'Use default booking group because setting is \'all\'' => 'Standardbuchungsgruppe wird verwendet',
+  'Use default booking group because wanted is missing' => 'Fehlende Buchungsgruppe, deshalb Standardbuchungsgruppe',
   'Use default warehouse for assembly transfer' => 'Zum Fertigen Standardlager des Bestandteils verwenden',
   'Use existing templates'      => 'Vorhandene Druckvorlagen verwenden',
   'Use linked items'            => 'Verknüpfte Positionen verwenden',
@@ -3329,6 +3341,7 @@ $self->{texts} = {
   'WHJournal'                   => 'Lagerbuchungen',
   'Warehouse'                   => 'Lager',
   'Warehouse (database ID)'     => 'Lager (Datenbank-ID)',
+  'Warehouse (name)'            => 'Lager (Name)',
   'Warehouse From'              => 'Quelllager',
   'Warehouse Migration'         => 'Lagermigration',
   'Warehouse To'                => 'Ziellager',
diff --git a/t/controllers/csvimport/parts.t b/t/controllers/csvimport/parts.t
new file mode 100644 (file)
index 0000000..94954ac
--- /dev/null
@@ -0,0 +1,312 @@
+use Test::More tests => 33;
+
+use strict;
+
+use lib 't';
+
+use Carp;
+use Data::Dumper;
+use Support::TestSetup;
+use Test::Exception;
+
+use List::MoreUtils qw(pairwise);
+use SL::Controller::CsvImport;
+
+my $DEBUG = 0;
+
+use_ok 'SL::Controller::CsvImport::Part';
+
+use SL::DB::Buchungsgruppe;
+use SL::DB::Currency;
+use SL::DB::Customer;
+use SL::DB::Language;
+use SL::DB::Warehouse;
+use SL::DB::Bin;
+
+my ($translation, $bin1_1, $bin1_2, $bin2_1, $bin2_2, $wh1, $wh2, $bugru, $cvarconfig );
+
+Support::TestSetup::login();
+
+sub reset_state {
+  # Create test data
+
+  clear_up();
+
+  $translation     = SL::DB::Language->new(
+    description    => 'Englisch',
+    article_code   => 'EN',
+    template_code  => 'EN',
+  )->save;
+  $translation     = SL::DB::Language->new(
+    description    => 'Italienisch',
+    article_code   => 'IT',
+    template_code  => 'IT',
+  )->save;
+  $wh1 = SL::DB::Warehouse->new(
+    description    => 'Lager1',
+    sortkey        => 1,
+  )->save;
+  $bin1_1 = SL::DB::Bin->new(
+    description    => 'Ort1_von_Lager1',
+    warehouse_id   => $wh1->id,
+  )->save;
+  $bin1_2 = SL::DB::Bin->new(
+    description    => 'Ort2_von_Lager1',
+    warehouse_id   => $wh1->id,
+  )->save;
+  $wh2 = SL::DB::Warehouse->new(
+    description    => 'Lager2',
+    sortkey        => 2,
+  )->save;
+  $bin2_1 = SL::DB::Bin->new(
+    description    => 'Ort1_von_Lager2',
+    warehouse_id   => $wh2->id,
+  )->save;
+  $bin2_2 = SL::DB::Bin->new(
+    description    => 'Ort2_von_Lager2',
+    warehouse_id   => $wh2->id,
+  )->save;
+
+  $cvarconfig = SL::DB::CustomVariableConfig->new(
+    module   => 'IC',
+    name     => 'mycvar',
+    type     => 'text',
+    description => 'mein schattz',
+    searchable  => 1,
+    sortkey => 1,
+    includeable => 0,
+    included_by_default => 0,
+  )->save;
+}
+
+$bugru = SL::DB::Manager::Buchungsgruppe->find_by(description => { like => 'Standard%19%' });
+
+reset_state();
+
+#####
+sub test_import {
+  my ($file,$settings) = @_;
+  my @profiles;
+  my $controller = SL::Controller::CsvImport->new();
+
+  my $csv_part_import = SL::Controller::CsvImport::Part->new(
+    settings   => $settings,
+    controller => $controller,
+    file       => $file,
+  );
+
+  $csv_part_import->init_bg_by;
+  $csv_part_import->init_price_factors_by;
+  $csv_part_import->init_partsgroups_by;
+  $csv_part_import->init_units_by;
+  $csv_part_import->init_bins_by;
+  $csv_part_import->init_warehouses_by;
+  $csv_part_import->init_parts_by;
+  $csv_part_import->test_run(0);
+  $csv_part_import->csv(SL::Helper::Csv->new(file                    => $csv_part_import->file,
+                                             profile                 => [{ profile => $csv_part_import->profile,
+                                                                           class   => $csv_part_import->class,
+                                                                           mapping => $csv_part_import->controller->mappings_for_profile }],
+                                             encoding                => 'utf-8',
+                                             ignore_unknown_columns  => 1,
+                                             strict_profile          => 1,
+                                             case_insensitive_header => 1,
+                                             sep_char                => ';',
+                                             quote_char              => '"',
+                                             ignore_unknown_columns  => 1,
+                                            ));
+
+  $csv_part_import->csv->parse;
+
+  $csv_part_import->controller->errors([ $csv_part_import->csv->errors ]) if $csv_part_import->csv->errors;
+
+  return if ( !$csv_part_import->csv->header || $csv_part_import->csv->errors );
+
+  my $headers         = { headers => [ grep { $csv_part_import->csv->dispatcher->is_known($_, 0) } @{ $csv_part_import->csv->header } ] };
+  $headers->{methods} = [ map { $_->{path} } @{ $csv_part_import->csv->specs->[0] } ];
+  $headers->{used}    = { map { ($_ => 1) }  @{ $headers->{headers} } };
+  $csv_part_import->controller->headers($headers);
+  $csv_part_import->controller->raw_data_headers({ used => { }, headers => [ ] });
+  $csv_part_import->controller->info_headers({ used => { }, headers => [ ] });
+
+  my $objects  = $csv_part_import->csv->get_objects;
+  my @raw_data = @{ $csv_part_import->csv->get_data };
+
+  $csv_part_import->controller->data([ pairwise { no warnings 'once'; { object => $a, raw_data => $b, errors => [], information => [], info_data => {} } } @$objects, @raw_data ]);
+
+  $csv_part_import->check_objects;
+
+  # don't try and save objects that have errors
+  $csv_part_import->save_objects unless scalar @{$csv_part_import->controller->data->[0]->{errors}};
+
+  return $csv_part_import->controller->data;
+}
+
+$::myconfig{numberformat} = '1000.00';
+my $old_locale = $::locale;
+# set locale to en so we can match errors
+$::locale = Locale->new('en');
+
+
+my ($entries, $entry, $file);
+
+# different settings for tests
+#
+
+my $settings1 = {
+                       sellprice_places          => 2,
+                       sellprice_adjustment      => 0,
+                       sellprice_adjustment_type => 'percent',
+                       article_number_policy     => 'update_prices',
+                       shoparticle_if_missing    => '0',
+                       parts_type                => 'part',
+                       default_buchungsgruppe    => ($bugru ? $bugru->id : undef),
+                       apply_buchungsgruppe      => 'all',
+                };
+my $settings2 = {
+                       sellprice_places          => 2,
+                       sellprice_adjustment      => 0,
+                       sellprice_adjustment_type => 'percent',
+                       article_number_policy     => 'update_parts',
+                       shoparticle_if_missing    => '0',
+                       parts_type                => 'part',
+                       default_buchungsgruppe    => ($bugru ? $bugru->id : undef),
+                       apply_buchungsgruppe      => 'missing',
+                       default_unit              => 'Stck',
+                };
+
+#
+#
+# starting test of csv imports
+# to debug errors in certain tests, run after test_import:
+#   die Dumper($entry->{errors});
+
+
+##### create part
+$file = \<<EOL;
+partnumber;sellprice;lastcost;listprice;unit
+P1000;100.10;90.20;95.30;kg
+EOL
+$entries = test_import($file,$settings1);
+$entry = $entries->[0];
+#foreach my $err ( @{ $entry->{errors} } ) {
+#  print $err;
+#}
+is $entry->{object}->partnumber,'P1000', 'partnumber';
+is $entry->{object}->sellprice, '100.1', 'sellprice';
+is $entry->{object}->lastcost,   '90.2', 'lastcost';
+is $entry->{object}->listprice,  '95.3', 'listprice';
+
+##### update prices of part
+$file = \<<EOL;
+partnumber;sellprice;lastcost;listprice;unit
+P1000;110.10;95.20;97.30;kg
+EOL
+$entries = test_import($file,$settings1);
+$entry = $entries->[0];
+is $entry->{object}->sellprice, '110.1', 'updated sellprice';
+is $entry->{object}->lastcost,   '95.2', 'updated lastcost';
+is $entry->{object}->listprice,  '97.3', 'updated listprice';
+
+##### insert parts with warehouse,bin name
+
+$file = \<<EOL;
+partnumber;description;warehouse;bin
+P1000;Teil 1000;Lager1;Ort1_von_Lager1
+P1001;Teil 1001;Lager1;Ort2_von_Lager1
+P1002;Teil 1002;Lager2;Ort1_von_Lager2
+P1003;Teil 1003;Lager2;Ort2_von_Lager2
+EOL
+$entries = test_import($file,$settings2);
+$entry = $entries->[0];
+is $entry->{object}->description, 'Teil 1000', 'Teil 1000 set';
+is $entry->{object}->warehouse_id, $wh1->id, 'Lager1';
+is $entry->{object}->bin_id, $bin1_1->id, 'Lagerort1';
+$entry = $entries->[2];
+is $entry->{object}->description, 'Teil 1002', 'Teil 1002 set';
+is $entry->{object}->warehouse_id, $wh2->id, 'Lager2';
+is $entry->{object}->bin_id, $bin2_1->id, 'Lagerort1';
+
+##### update warehouse and bin
+$file = \<<EOL;
+partnumber;description;warehouse;bin
+P1000;Teil 1000;Lager2;Ort1_von_Lager2
+P1001;Teil 1001;Lager1;Ort1_von_Lager1
+P1002;Teil 1002;Lager2;Ort1_von_Lager1
+P1003;Teil 1003;Lager2;kein Lagerort
+EOL
+$entries = test_import($file,$settings2);
+$entry = $entries->[0];
+is $entry->{object}->description, 'Teil 1000', 'Teil 1000 set';
+is $entry->{object}->warehouse_id, $wh2->id, 'Lager2';
+is $entry->{object}->bin_id, $bin2_1->id, 'Lagerort1';
+$entry = $entries->[2];
+my $err1 = @{ $entry->{errors} }[0];
+#print "'".$err1."'\n";
+is $entry->{object}->description, 'Teil 1002', 'Teil 1002 set';
+is $entry->{object}->warehouse_id, $wh2->id, 'Lager2';
+is $err1, 'Error: Bin Ort1_von_Lager1 is not from warehouse Lager2','kein Lager von Lager2';
+$entry = $entries->[3];
+$err1 = @{ $entry->{errors} }[0];
+#print "'".$err1."'\n";
+is $entry->{object}->description, 'Teil 1003', 'Teil 1003 set';
+is $entry->{object}->warehouse_id, $wh2->id, 'Lager2';
+is $err1, 'Error: Invalid bin name kein Lagerort','kein Lagerort';
+
+##### add translations
+$file = \<<EOL;
+partnumber;description;description_EN;notes_EN;description_IT;notes_IT
+P1000;Teil 1000;descr EN 1000;notes EN;descr IT 1000;notes IT
+P1001;Teil 1001;descr EN 1001;notes EN;descr IT 1001;notes IT
+P1002;Teil 1002;descr EN 1002;notes EN;descr IT 1002;notes IT
+P1003;Teil 1003;descr EN 1003;notes EN;descr IT 1003;notes IT
+EOL
+$entries = test_import($file,$settings2);
+$entry = $entries->[0];
+is $entry->{object}->description, 'Teil 1000', 'Teil 1000 set';
+is $entry->{raw_data}->{description_EN},'descr EN 1000','EN set';
+is $entry->{raw_data}->{description_IT},'descr IT 1000','IT set';
+my $l = @{$entry->{object}->translations}[0];
+is $l->translation,'descr EN 1000','EN trans set';
+is $l->longdescription, 'notes EN','EN notes set';
+$l = @{$entry->{object}->translations}[1];
+is $l->translation,'descr IT 1000','IT trans set';
+is $l->longdescription, 'notes IT','IT notes set';
+
+##### add customvar
+$file = \<<EOL;
+partnumber;cvar_mycvar
+P1000;das ist der ring
+P1001;nicht der nibelungen
+P1002;sondern vom
+P1003;Herr der Ringe
+EOL
+$entries = test_import($file,$settings2);
+$entry = $entries->[0];
+is $entry->{object}->partnumber, 'P1000', 'P1000 set';
+is $entry->{raw_data}->{cvar_mycvar},'das ist der ring','CVAR set';
+is @{$entry->{object}->custom_variables}[0]->text_value,'das ist der ring','Cvar mit richtigem Weert';
+
+clear_up(); # remove all data at end of tests
+
+# end of tests
+
+
+sub clear_up {
+  SL::DB::Manager::Part       ->delete_all(all => 1);
+  SL::DB::Manager::Translation->delete_all(all => 1);
+  SL::DB::Manager::Language   ->delete_all(all => 1);
+  SL::DB::Manager::Bin        ->delete_all(all => 1);
+  SL::DB::Manager::Warehouse  ->delete_all(all => 1);
+  SL::DB::Manager::CustomVariableConfig->delete_all(all => 1);
+}
+
+
+1;
+
+#####
+# vim: ft=perl
+# set emacs to perl mode
+# Local Variables:
+# mode: perl
+# End:
index b2e8af6..8615aa5 100644 (file)
@@ -3,7 +3,7 @@
 <tr>
  <th align="right">[%- LxERP.t8('Parts with existing part numbers') %]:</th>
  <td colspan="10">
-  [% opts = [ [ 'update_prices', LxERP.t8('Update prices of existing entries') ], [ 'insert_new', LxERP.t8('Insert with new part number') ], [ 'skip', LxERP.t8('Skip entry') ] ] %]
+  [% opts = [[ 'update_parts', LxERP.t8('Update properties of existing entries') ], [ 'update_parts_sn', LxERP.t8('Update properties of existing entries / skip non-existent') ], [ 'update_prices', LxERP.t8('Update prices of existing entries') ],[ 'update_prices_sn', LxERP.t8('Update prices of existing entries / skip non-existent') ] ,[ 'insert_new', LxERP.t8('Insert with new part number') ], [ 'skip', LxERP.t8('Skip entry') ] ] %]
   [% L.select_tag('settings.article_number_policy', opts, default = SELF.profile.get('article_number_policy'), style = 'width: 300px') %]
  </td>
 </tr>