Merge branch 'csv-import-in-perl'
authorMoritz Bunkus <m.bunkus@linet-services.de>
Thu, 16 Jun 2011 07:34:21 +0000 (09:34 +0200)
committerMoritz Bunkus <m.bunkus@linet-services.de>
Thu, 16 Jun 2011 07:34:21 +0000 (09:34 +0200)
35 files changed:
.gitignore
SL/Auth.pm
SL/Controller/Base.pm
SL/Controller/CsvImport.pm [new file with mode: 0644]
SL/Controller/CsvImport/Base.pm [new file with mode: 0644]
SL/Controller/CsvImport/Contact.pm [new file with mode: 0644]
SL/Controller/CsvImport/CustomerVendor.pm [new file with mode: 0644]
SL/Controller/CsvImport/Part.pm [new file with mode: 0644]
SL/Controller/CsvImport/Shipto.pm [new file with mode: 0644]
SL/DB/CsvImportProfile.pm [new file with mode: 0644]
SL/DB/CsvImportProfileSetting.pm [new file with mode: 0644]
SL/DB/Customer.pm
SL/DB/Helper/ALL.pm
SL/DB/Helper/Mappings.pm
SL/DB/Helper/TransNumberGenerator.pm
SL/DB/Manager/Customer.pm [new file with mode: 0644]
SL/DB/MetaSetup/CsvImportProfile.pm [new file with mode: 0644]
SL/DB/MetaSetup/CsvImportProfileSetting.pm [new file with mode: 0644]
SL/DB/Object/Hooks.pm
SL/DB/Part.pm
SL/DB/Vendor.pm
SL/Helper/Csv.pm [new file with mode: 0644]
SL/Helper/Csv/Dispatcher.pm [new file with mode: 0644]
SL/Helper/Csv/Error.pm [new file with mode: 0644]
SL/SessionFile.pm [new file with mode: 0644]
locale/de/all
menu.ini
sql/Pg-upgrade2/csv_import_profiles.sql [new file with mode: 0644]
t/helper/csv.t [new file with mode: 0644]
templates/webpages/csv_import/_errors.html [new file with mode: 0644]
templates/webpages/csv_import/_form_customers_vendors.html [new file with mode: 0644]
templates/webpages/csv_import/_form_parts.html [new file with mode: 0644]
templates/webpages/csv_import/_preview.html [new file with mode: 0644]
templates/webpages/csv_import/_result.html [new file with mode: 0644]
templates/webpages/csv_import/form.html [new file with mode: 0644]

index d512181..81d3e49 100644 (file)
@@ -2,6 +2,7 @@ tags
 crm
 /users/datev-export*
 /users/templates-cache/
+/users/session_files/
 /users/pid/
 /config/lx_office.conf
 /doc/online/*/*.html
index adf5810..784b185 100644 (file)
@@ -12,6 +12,7 @@ use SL::Auth::Constants qw(:all);
 use SL::Auth::DB;
 use SL::Auth::LDAP;
 
+use SL::SessionFile;
 use SL::User;
 use SL::DBConnect;
 use SL::DBUpgrade2;
@@ -555,6 +556,8 @@ sub destroy_session {
 
     $dbh->commit();
 
+    SL::SessionFile->destroy_session($session_id);
+
     $session_id      = undef;
     $self->{SESSION} = { };
   }
@@ -571,24 +574,27 @@ sub expire_sessions {
 
   my $dbh   = $self->dbconnect();
 
-  $dbh->begin_work;
+  my $query = qq|SELECT id
+                 FROM auth.session
+                 WHERE (mtime < (now() - '$self->{session_timeout}m'::interval))|;
 
-  my $query =
-    qq|DELETE FROM auth.session_content
-       WHERE session_id IN
-         (SELECT id
-          FROM auth.session
-          WHERE (mtime < (now() - '$self->{session_timeout}m'::interval)))|;
+  my @ids   = selectall_array_query($::form, $dbh, $query);
 
-  do_query($main::form, $dbh, $query);
+  if (@ids) {
+    $dbh->begin_work;
 
-  $query =
-    qq|DELETE FROM auth.session
-       WHERE (mtime < (now() - '$self->{session_timeout}m'::interval))|;
+    SL::SessionFile->destroy_session($_) for @ids;
 
-  do_query($main::form, $dbh, $query);
+    $query = qq|DELETE FROM auth.session_content
+                WHERE session_id IN (| . join(', ', ('?') x scalar(@ids)) . qq|)|;
+    do_query($main::form, $dbh, $query, @ids);
 
-  $dbh->commit();
+    $query = qq|DELETE FROM auth.session
+                WHERE id IN (| . join(', ', ('?') x scalar(@ids)) . qq|)|;
+    do_query($main::form, $dbh, $query, @ids);
+
+    $dbh->commit();
+  }
 
   $main::lxdebug->leave_sub();
 }
index d50d519..152fbb7 100644 (file)
@@ -174,9 +174,13 @@ sub _dispatch {
   my $action  = first { $::form->{"action_${_}"} } @actions;
   my $sub     = "action_${action}";
 
-  $self->_run_hooks('before', $action);
-  $self->$sub(@_);
-  $self->_run_hooks('after', $action);
+  if ($self->can($sub)) {
+    $self->_run_hooks('before', $action);
+    $self->$sub(@_);
+    $self->_run_hooks('after', $action);
+  } else {
+    $::form->error($::locale->text('Oops. No valid action found to dispatch. Please report this case to the Lx-Office team.'));
+  }
 }
 
 sub _template_obj {
diff --git a/SL/Controller/CsvImport.pm b/SL/Controller/CsvImport.pm
new file mode 100644 (file)
index 0000000..c30634b
--- /dev/null
@@ -0,0 +1,265 @@
+package SL::Controller::CsvImport;
+
+use strict;
+
+use SL::DB::Buchungsgruppe;
+use SL::DB::CsvImportProfile;
+use SL::Helper::Flash;
+use SL::SessionFile;
+use SL::Controller::CsvImport::Contact;
+use SL::Controller::CsvImport::CustomerVendor;
+use SL::Controller::CsvImport::Part;
+use SL::Controller::CsvImport::Shipto;
+
+use List::MoreUtils qw(none);
+
+use parent qw(SL::Controller::Base);
+
+use Rose::Object::MakeMethods::Generic
+(
+ scalar => [ qw(type profile file all_profiles all_charsets sep_char all_sep_chars quote_char all_quote_chars escape_char all_escape_chars all_buchungsgruppen
+                import_status errors headers raw_data_headers info_headers data num_imported num_importable displayable_columns) ],
+);
+
+__PACKAGE__->run_before('check_auth');
+__PACKAGE__->run_before('ensure_form_structure');
+__PACKAGE__->run_before('check_type');
+__PACKAGE__->run_before('load_all_profiles');
+
+#
+# actions
+#
+
+sub action_new {
+  my ($self) = @_;
+
+  $self->load_default_profile unless $self->profile;
+  $self->render_inputs;
+}
+
+sub action_test {
+  my ($self) = @_;
+  $self->test_and_import(test => 1);
+}
+
+sub action_import {
+  my $self = shift;
+  $self->test_and_import(test => 0);
+}
+
+sub action_save {
+  my ($self) = @_;
+
+  $self->profile_from_form(SL::DB::Manager::CsvImportProfile->find_by(name => $::form->{profile}->{name}));
+  $self->profile->save;
+
+  flash_later('info', $::locale->text("The profile has been saved under the name '#1'.", $self->profile->name));
+  $self->redirect_to(action => 'new', 'profile.type' => $self->type, 'profile.id' => $self->profile->id);
+}
+
+sub action_destroy {
+  my $self = shift;
+
+  my $profile = SL::DB::CsvImportProfile->new(id => $::form->{profile}->{id});
+  $profile->delete(cascade => 1);
+
+  flash_later('info', $::locale->text('The profile \'#1\' has been deleted.', $profile->name));
+  $self->redirect_to(action => 'new', 'profile.type' => $self->type);
+}
+
+sub action_download_sample {
+  my $self = shift;
+
+  $self->profile_from_form;
+  $self->setup_help;
+
+  my $file_name = 'csv_import_sample_' . $self->type . '.csv';
+  my $file      = SL::SessionFile->new($file_name, mode => '>', encoding => $self->profile->get('charset'));
+  my $csv       = Text::CSV_XS->new({ binary => 1, map { ( $_ => $self->profile->get($_) ) } qw(sep_char escape_char quote_char),});
+
+  $csv->print($file->fh, [ map { $_->{name}        } @{ $self->displayable_columns } ]);
+  $file->fh->print("\r\n");
+  $csv->print($file->fh, [ map { $_->{description} } @{ $self->displayable_columns } ]);
+  $file->fh->print("\r\n");
+
+  $file->fh->close;
+
+  $self->send_file($file->file_name);
+}
+
+#
+# filters
+#
+
+sub check_auth {
+  $::auth->assert('config');
+}
+
+sub check_type {
+  my ($self) = @_;
+
+  die "Invalid CSV import type" if none { $_ eq $::form->{profile}->{type} } qw(parts customers_vendors addresses contacts);
+  $self->type($::form->{profile}->{type});
+}
+
+sub ensure_form_structure {
+  my ($self, %params) = @_;
+
+  $::form->{profile}  = {} unless ref $::form->{profile}  eq 'HASH';
+  $::form->{settings} = {} unless ref $::form->{settings} eq 'HASH';
+}
+
+#
+# helpers
+#
+
+sub render_inputs {
+  my ($self, %params) = @_;
+
+  $self->all_charsets([ [ 'UTF-8',       'UTF-8'                 ],
+                        [ 'ISO-8859-1',  'ISO-8859-1 (Latin 1)'  ],
+                        [ 'ISO-8859-15', 'ISO-8859-15 (Latin 9)' ],
+                        [ 'CP850',       'CP850 (DOS/ANSI)'      ],
+                        [ 'CP1252',      'CP1252 (Windows)'      ],
+                      ]);
+
+  my %char_map = $self->char_map;
+
+  foreach my $type (qw(sep quote escape)) {
+    my $sub = "all_${type}_chars";
+    $self->$sub([ sort { $a->[0] cmp $b->[0] } values %{ $char_map{$type} } ]);
+
+    my $char = $self->profile->get($type . '_char');
+    $sub     = "${type}_char";
+    $self->$sub(($char_map{$type}->{$char} || [])->[0] || $char);
+  }
+
+  $self->file(SL::SessionFile->new($self->csv_file_name));
+
+  my $title = $self->type eq 'customers_vendors' ? $::locale->text('CSV import: customers and vendors')
+            : $self->type eq 'addresses'         ? $::locale->text('CSV import: shipping addresses')
+            : $self->type eq 'contacts'          ? $::locale->text('CSV import: contacts')
+            : $self->type eq 'parts'             ? $::locale->text('CSV import: parts and services')
+            : die;
+
+  $self->all_buchungsgruppen(SL::DB::Manager::Buchungsgruppe->get_all_sorted);
+
+  $self->setup_help;
+
+  $self->render('csv_import/form', title => $title);
+}
+
+sub test_and_import {
+  my ($self, %params) = @_;
+
+  $self->profile_from_form;
+
+  if ($::form->{file}) {
+    my $file = SL::SessionFile->new($self->csv_file_name, mode => '>');
+    $file->fh->print($::form->{file});
+    $file->fh->close;
+  }
+
+  my $file = SL::SessionFile->new($self->csv_file_name, mode => '<', encoding => $self->profile->get('charset'));
+  if (!$file->fh) {
+    flash('error', $::locale->text('No file has been uploaded yet.'));
+    return $self->action_new;
+  }
+
+  my $worker = $self->create_worker($file);
+  $worker->run;
+  $worker->save_objects if !$params{test};
+
+  $self->num_importable(scalar grep { !$_ } map { scalar @{ $_->{errors} } } @{ $self->data || [] });
+  $self->import_status($params{test} ? 'tested' : 'imported');
+
+  flash('info', $::locale->text('Objects have been imported.')) if !$params{test};
+
+  $self->action_new;
+}
+
+sub load_default_profile {
+  my ($self) = @_;
+
+  if ($::form->{profile}->{id}) {
+    $self->profile(SL::DB::CsvImportProfile->new(id => $::form->{profile}->{id})->load);
+
+  } else {
+    $self->profile(SL::DB::Manager::CsvImportProfile->find_by(type => $self->{type}, is_default => 1));
+    $self->profile(SL::DB::CsvImportProfile->new(type => $self->{type})) unless $self->profile;
+  }
+
+  $self->profile->set_defaults;
+}
+
+sub load_all_profiles {
+  my ($self, %params) = @_;
+
+  $self->all_profiles(SL::DB::Manager::CsvImportProfile->get_all(where => [ type => $self->type ], sort_by => 'name'));
+}
+
+sub profile_from_form {
+  my ($self, $existing_profile) = @_;
+
+  delete $::form->{profile}->{id};
+
+  my %char_map = $self->char_map;
+  my @settings;
+
+  foreach my $type (qw(sep quote escape)) {
+    my %rev_chars = map { $char_map{$type}->{$_}->[0] => $_ } keys %{ $char_map{$type} };
+    my $char      = $::form->{"${type}_char"} eq 'custom' ? $::form->{"custom_${type}_char"} : $rev_chars{ $::form->{"${type}_char"} };
+
+    push @settings, { key => "${type}_char", value => $char };
+  }
+
+  if ($self->type eq 'parts') {
+    $::form->{settings}->{sellprice_adjustment} = $::form->parse_amount(\%::myconfig, $::form->{settings}->{sellprice_adjustment});
+  }
+
+  delete $::form->{profile}->{id};
+  $self->profile($existing_profile || SL::DB::CsvImportProfile->new);
+  $self->profile->assign_attributes(%{ $::form->{profile} });
+  $self->profile->settings(map({ { key => $_, value => $::form->{settings}->{$_} } } keys %{ $::form->{settings} }),
+                           @settings);
+  $self->profile->set_defaults;
+}
+
+sub char_map {
+  return ( sep    => { ','  => [ 'comma',     $::locale->text('Comma')     ],
+                       ';'  => [ 'semicolon', $::locale->text('Semicolon') ],
+                       "\t" => [ 'tab',       $::locale->text('Tab')       ],
+                       ' '  => [ 'space',     $::locale->text('Space')     ],
+                     },
+           quote  => { '"' => [ 'quote', $::locale->text('Quotes') ],
+                       "'" => [ 'singlequote', $::locale->text('Single quotes') ],
+                     },
+           escape => { '"' => [ 'quote', $::locale->text('Quotes') ],
+                       "'" => [ 'singlequote', $::locale->text('Single quotes') ],
+                     },
+         );
+}
+
+sub csv_file_name {
+  my ($self) = @_;
+  return "csv-import-" . $self->type . ".csv";
+}
+
+sub create_worker {
+  my ($self, $file) = @_;
+
+  return $self->{type} eq 'customers_vendors' ? SL::Controller::CsvImport::CustomerVendor->new(controller => $self, file => $file)
+       : $self->{type} eq 'contacts'          ? SL::Controller::CsvImport::Contact->new(       controller => $self, file => $file)
+       : $self->{type} eq 'addresses'         ? SL::Controller::CsvImport::Shipto->new(        controller => $self, file => $file)
+       : $self->{type} eq 'parts'             ? SL::Controller::CsvImport::Part->new(          controller => $self, file => $file)
+       :                                        die "Program logic error";
+}
+
+sub setup_help {
+  my ($self) = @_;
+
+  $self->create_worker->setup_displayable_columns;
+}
+
+
+1;
diff --git a/SL/Controller/CsvImport/Base.pm b/SL/Controller/CsvImport/Base.pm
new file mode 100644 (file)
index 0000000..6a66e8c
--- /dev/null
@@ -0,0 +1,329 @@
+package SL::Controller::CsvImport::Base;
+
+use strict;
+
+use List::MoreUtils qw(pairwise);
+
+use SL::Helper::Csv;
+use SL::DB::Customer;
+use SL::DB::Language;
+use SL::DB::PaymentTerm;
+use SL::DB::Vendor;
+
+use parent qw(Rose::Object);
+
+use Rose::Object::MakeMethods::Generic
+(
+ scalar                  => [ qw(controller file csv) ],
+ 'scalar --get_set_init' => [ qw(profile displayable_columns existing_objects class manager_class cvar_columns all_cvar_configs all_languages payment_terms_by all_vc vc_by) ],
+);
+
+sub run {
+  my ($self) = @_;
+
+  my $profile = $self->profile;
+  $self->csv(SL::Helper::Csv->new(file                   => $self->file->file_name,
+                                  encoding               => $self->controller->profile->get('charset'),
+                                  class                  => $self->class,
+                                  profile                => $profile,
+                                  ignore_unknown_columns => 1,
+                                  strict_profile         => 1,
+                                  map { ( $_ => $self->controller->profile->get($_) ) } qw(sep_char escape_char quote_char),
+                                 ));
+
+  my $old_numberformat      = $::myconfig{numberformat};
+  $::myconfig{numberformat} = $self->controller->profile->get('numberformat');
+
+  $self->csv->parse;
+
+  $self->controller->errors([ $self->csv->errors ]) if $self->csv->errors;
+
+  return unless $self->csv->header;
+
+  my $headers         = { headers => [ grep { $profile->{$_} } @{ $self->csv->header } ] };
+  $headers->{methods} = [ map { $profile->{$_} } @{ $headers->{headers} } ];
+  $headers->{used}    = { map { ($_ => 1) }      @{ $headers->{headers} } };
+  $self->controller->headers($headers);
+  $self->controller->raw_data_headers({ used => { }, headers => [ ] });
+  $self->controller->info_headers({ used => { }, headers => [ ] });
+
+  my @objects  = $self->csv->get_objects;
+  my @raw_data = @{ $self->csv->get_data };
+  $self->controller->data([ pairwise { { object => $a, raw_data => $b, errors => [], information => [], info_data => {} } } @objects, @raw_data ]);
+
+  $self->check_objects;
+  $self->check_duplicates if $self->controller->profile->get('duplicates', 'no_check') ne 'no_check';
+  $self->fix_field_lengths;
+
+  $::myconfig{numberformat} = $old_numberformat;
+}
+
+sub add_columns {
+  my ($self, @columns) = @_;
+
+  my $h = $self->controller->headers;
+
+  foreach my $column (grep { !$h->{used}->{$_} } @columns) {
+    $h->{used}->{$column} = 1;
+    push @{ $h->{methods} }, $column;
+    push @{ $h->{headers} }, $column;
+  }
+}
+
+sub add_info_columns {
+  my ($self, @columns) = @_;
+
+  my $h = $self->controller->info_headers;
+
+  foreach my $column (grep { !$h->{used}->{ $_->{method} } } map { ref $_ eq 'HASH' ? $_ : { method => $_, header => $_ } } @columns) {
+    $h->{used}->{ $column->{method} } = 1;
+    push @{ $h->{methods} }, $column->{method};
+    push @{ $h->{headers} }, $column->{header};
+  }
+}
+
+sub add_raw_data_columns {
+  my ($self, @columns) = @_;
+
+  my $h = $self->controller->raw_data_headers;
+
+  foreach my $column (grep { !$h->{used}->{$_} } @columns) {
+    $h->{used}->{$column} = 1;
+    push @{ $h->{headers} }, $column;
+  }
+}
+
+sub add_cvar_raw_data_columns {
+  my ($self) = @_;
+
+  map { $self->add_raw_data_columns($_) if exists $self->controller->data->[0]->{raw_data}->{$_} } @{ $self->cvar_columns };
+}
+
+sub init_cvar_columns {
+  my ($self) = @_;
+
+  return [ map { "cvar_" . $_->name } (@{ $self->all_cvar_configs }) ];
+}
+
+sub init_all_languages {
+  my ($self) = @_;
+
+  return SL::DB::Manager::Language->get_all;
+}
+
+sub init_payment_terms_by {
+  my ($self) = @_;
+
+  my $all_payment_terms = SL::DB::Manager::PaymentTerm->get_all;
+  return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $all_payment_terms } } ) } qw(id description) };
+}
+
+sub init_all_vc {
+  my ($self) = @_;
+
+  return { customers => SL::DB::Manager::Customer->get_all,
+           vendors   => SL::DB::Manager::Vendor->get_all };
+}
+
+sub init_vc_by {
+  my ($self)    = @_;
+
+  my %by_id     = map { ( $_->id => $_ ) } @{ $self->all_vc->{customers} }, @{ $self->all_vc->{vendors} };
+  my %by_number = ( customers => { map { ( $_->customernumber => $_ ) } @{ $self->all_vc->{customers} } },
+                    vendors   => { map { ( $_->vendornumber   => $_ ) } @{ $self->all_vc->{vendors}   } } );
+  my %by_name   = ( customers => { map { ( $_->name           => $_ ) } @{ $self->all_vc->{customers} } },
+                    vendors   => { map { ( $_->name           => $_ ) } @{ $self->all_vc->{vendors}   } } );
+
+  return { id     => \%by_id,
+           number => \%by_number,
+           name   => \%by_name,   };
+}
+
+sub check_vc {
+  my ($self, $entry, $id_column) = @_;
+
+  if ($entry->{object}->$id_column) {
+    $entry->{object}->$id_column(undef) if !$self->vc_by->{id}->{ $entry->{object}->$id_column };
+  }
+
+  if (!$entry->{object}->$id_column) {
+    my $vc = $self->vc_by->{number}->{customers}->{ $entry->{raw_data}->{customernumber} }
+          || $self->vc_by->{number}->{vendors}->{   $entry->{raw_data}->{vendornumber}   };
+    $entry->{object}->$id_column($vc->id) if $vc;
+  }
+
+  if (!$entry->{object}->$id_column) {
+    my $vc = $self->vc_by->{name}->{customers}->{ $entry->{raw_data}->{customer} }
+          || $self->vc_by->{name}->{vendors}->{   $entry->{raw_data}->{vendor}   };
+    $entry->{object}->$id_column($vc->id) if $vc;
+  }
+
+  if ($entry->{object}->$id_column) {
+    $entry->{info_data}->{vc_name} = $self->vc_by->{id}->{ $entry->{object}->$id_column }->name;
+  } else {
+    push @{ $entry->{errors} }, $::locale->text('Error: Customer/vendor not found');
+  }
+}
+
+sub handle_cvars {
+  my ($self, $entry) = @_;
+
+  return unless $self->can('all_cvar_configs');
+
+  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;
+  foreach my $config (@{ $self->all_cvar_configs }) {
+    next unless exists $entry->{raw_data}->{ "cvar_" . $config->name };
+    my $value  = $entry->{raw_data}->{ "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);
+  }
+
+  $entry->{object}->custom_variables(\@cvars);
+}
+
+sub init_profile {
+  my ($self) = @_;
+
+  eval "require " . $self->class;
+
+  my %unwanted = map { ( $_ => 1 ) } (qw(itime mtime), map { $_->name } @{ $self->class->meta->primary_key_columns });
+  my %profile;
+  for my $col ($self->class->meta->columns) {
+    next if $unwanted{$col};
+
+    my $name = $col->isa('Rose::DB::Object::Metadata::Column::Numeric')   ? "$col\_as_number"
+      :        $col->isa('Rose::DB::Object::Metadata::Column::Date')      ? "$col\_as_date"
+      :        $col->isa('Rose::DB::Object::Metadata::Column::Timestamp') ? "$col\_as_date"
+      :                                                                     $col->name;
+
+    $profile{$col} = $name;
+  }
+
+  $self->profile(\%profile);
+}
+
+sub add_displayable_columns {
+  my ($self, @columns) = @_;
+
+  my @cols       = @{ $self->controller->displayable_columns || [] };
+  my %ex_col_map = map { $_->{name} => $_ } @cols;
+
+  foreach my $column (@columns) {
+    if ($ex_col_map{ $column->{name} }) {
+      @{ $ex_col_map{ $column->{name} } }{ keys %{ $column } } = @{ $column }{ keys %{ $column } };
+    } else {
+      push @cols, $column;
+    }
+  }
+
+  $self->controller->displayable_columns([ sort { $a->{name} cmp $b->{name} } @cols ]);
+}
+
+sub setup_displayable_columns {
+  my ($self) = @_;
+
+  $self->add_displayable_columns(map { { name => $_ } } keys %{ $self->profile });
+}
+
+sub add_cvar_columns_to_displayable_columns {
+  my ($self) = @_;
+
+  return unless $self->can('all_cvar_configs');
+
+  $self->add_displayable_columns(map { { name        => 'cvar_' . $_->name,
+                                         description => $::locale->text('#1 (custom variable)', $_->description) } }
+                                     @{ $self->all_cvar_configs });
+}
+
+sub init_existing_objects {
+  my ($self) = @_;
+
+  eval "require " . $self->class;
+  $self->existing_objects($self->manager_class->get_all);
+}
+
+sub init_class {
+  die "class not set";
+}
+
+sub init_manager_class {
+  my ($self) = @_;
+
+  $self->class =~ m/^SL::DB::(.+)/;
+  $self->manager_class("SL::DB::Manager::" . $1);
+}
+
+sub check_objects {
+}
+
+sub check_duplicates {
+}
+
+sub check_payment {
+  my ($self, $entry) = @_;
+
+  my $object = $entry->{object};
+
+  # Check whether or not payment ID is valid.
+  if ($object->payment_id && !$self->payment_terms_by->{id}->{ $object->payment_id }) {
+    push @{ $entry->{errors} }, $::locale->text('Error: Invalid payment terms');
+    return 0;
+  }
+
+  # Map name to ID if given.
+  if (!$object->payment_id && $entry->{raw_data}->{payment}) {
+    my $terms = $self->payment_terms_by->{description}->{ $entry->{raw_data}->{payment} };
+
+    if (!$terms) {
+      push @{ $entry->{errors} }, $::locale->text('Error: Invalid payment terms');
+      return 0;
+    }
+
+    $object->payment_id($terms->id);
+  }
+
+  return 1;
+}
+
+sub save_objects {
+  my ($self, %params) = @_;
+
+  my $data = $params{data} || $self->controller->data;
+
+  foreach my $entry (@{ $data }) {
+    next if @{ $entry->{errors} };
+
+    my $object = $entry->{object_to_save} || $entry->{object};
+
+    if (!$object->save) {
+      push @{ $entry->{errors} }, $::locale->text('Error when saving: #1', $entry->{object}->db->error);
+    } else {
+      $self->controller->num_imported($self->controller->num_imported + 1);
+    }
+  }
+}
+
+sub field_lengths {
+  return ();
+}
+
+sub fix_field_lengths {
+  my ($self) = @_;
+
+  my %field_lengths = $self->field_lengths;
+  foreach my $entry (@{ $self->controller->data }) {
+    next unless @{ $entry->{errors} };
+    map { $entry->{object}->$_(substr($entry->{object}->$_, 0, $field_lengths{$_})) if $entry->{object}->$_ } keys %field_lengths;
+  }
+}
+
+1;
diff --git a/SL/Controller/CsvImport/Contact.pm b/SL/Controller/CsvImport/Contact.pm
new file mode 100644 (file)
index 0000000..e5dadbb
--- /dev/null
@@ -0,0 +1,117 @@
+package SL::Controller::CsvImport::Contact;
+
+use strict;
+
+use SL::Helper::Csv;
+
+use parent qw(SL::Controller::CsvImport::Base);
+
+use Rose::Object::MakeMethods::Generic
+(
+ scalar => [ qw(table) ],
+);
+
+sub init_class {
+  my ($self) = @_;
+  $self->class('SL::DB::Contact');
+}
+
+sub check_objects {
+  my ($self) = @_;
+
+  foreach my $entry (@{ $self->controller->data }) {
+    $self->check_name($entry);
+    $self->check_vc($entry, 'cp_cv_id');
+    $self->check_gender($entry);
+  }
+
+  $self->add_info_columns({ header => $::locale->text('Customer/Vendor'), method => 'vc_name' });
+}
+
+sub check_name {
+  my ($self, $entry) = @_;
+
+  my $name     =  $entry->{object}->cp_name;
+  $name        =~ s/^\s+//;
+  $name        =~ s/\s+$//;
+
+  push @{ $entry->{errors} }, $::locale->text('Error: Name missing') unless $name;
+}
+
+sub check_gender {
+  my ($self, $entry) = @_;
+
+  push @{ $entry->{errors} }, $::locale->text('Error: Gender (cp_gender) missing or invalid') if ($entry->{object}->cp_gender ne 'm') && ($entry->{object}->cp_gender ne 'f');
+}
+
+sub check_duplicates {
+  my ($self, %params) = @_;
+
+  my $normalizer = sub { my $name = $_[0]; $name =~ s/[\s,\.\-]//g; return $name; };
+
+  my %by_id_and_name;
+  if ('check_db' eq $self->controller->profile->get('duplicates')) {
+    foreach my $type (qw(customers vendors)) {
+      foreach my $vc (@{ $self->all_vc->{$type} }) {
+        $by_id_and_name{ $vc->id } = { map { ( $normalizer->($_->cp_name) => 'db' ) } @{ $vc->contacts } };
+      }
+    }
+  }
+
+  foreach my $entry (@{ $self->controller->data }) {
+    next if @{ $entry->{errors} };
+
+    my $name = $normalizer->($entry->{object}->cp_name);
+
+    $by_id_and_name{ $entry->{vc}->id } ||= { };
+    if (!$by_id_and_name{ $entry->{vc}->id }->{ $name }) {
+      $by_id_and_name{ $entry->{vc}->id }->{ $name } = 'csv';
+
+    } else {
+      push @{ $entry->{errors} }, $by_id_and_name{ $entry->{vc}->id }->{ $name } eq 'db' ? $::locale->text('Duplicate in database') : $::locale->text('Duplicate in CSV file');
+    }
+  }
+}
+
+sub field_lengths {
+  return ( cp_title     => 75,
+           cp_givenname => 75,
+           cp_name      => 75,
+           cp_phone1    => 75,
+           cp_phone2    => 75,
+           cp_gender    =>  1,
+         );
+}
+
+sub setup_displayable_columns {
+  my ($self) = @_;
+
+  $self->SUPER::setup_displayable_columns;
+
+  $self->add_displayable_columns({ name => 'cp_abteilung',   description => $::locale->text('Department')                    },
+                                 { name => 'cp_birthday',    description => $::locale->text('Birthday')                      },
+                                 { name => 'cp_cv_id',       description => $::locale->text('Customer/Vendor (database ID)') },
+                                 { name => 'cp_email',       description => $::locale->text('E-mail')                        },
+                                 { name => 'cp_fax',         description => $::locale->text('Fax')                           },
+                                 { name => 'cp_gender',      description => $::locale->text('Gender')                        },
+                                 { name => 'cp_givenname',   description => $::locale->text('Given Name')                    },
+                                 { name => 'cp_mobile1',     description => $::locale->text('Mobile1')                       },
+                                 { name => 'cp_mobile2',     description => $::locale->text('Mobile2')                       },
+                                 { name => 'cp_name',        description => $::locale->text('Name')                          },
+                                 { name => 'cp_phone1',      description => $::locale->text('Phone1')                        },
+                                 { name => 'cp_phone2',      description => $::locale->text('Phone2')                        },
+                                 { name => 'cp_privatemail', description => $::locale->text('Private E-mail')                },
+                                 { name => 'cp_privatphone', description => $::locale->text('Private Phone')                 },
+                                 { name => 'cp_project',     description => $::locale->text('Project')                       },
+                                 { name => 'cp_satfax',      description => $::locale->text('Sat. Fax')                      },
+                                 { name => 'cp_satphone',    description => $::locale->text('Sat. Phone')                    },
+                                 { name => 'cp_title',       description => $::locale->text('Title')                         },
+
+                                 { name => 'customer',       description => $::locale->text('Customer (name)')               },
+                                 { name => 'customernumber', description => $::locale->text('Customer Number')               },
+                                 { name => 'vendor',         description => $::locale->text('Vendor (name)')                 },
+                                 { name => 'vendornumber',   description => $::locale->text('Vendor Number')                 },
+                                );
+}
+
+1;
diff --git a/SL/Controller/CsvImport/CustomerVendor.pm b/SL/Controller/CsvImport/CustomerVendor.pm
new file mode 100644 (file)
index 0000000..e42a93d
--- /dev/null
@@ -0,0 +1,257 @@
+package SL::Controller::CsvImport::CustomerVendor;
+
+use strict;
+
+use SL::Helper::Csv;
+use SL::DB::Business;
+use SL::DB::CustomVariable;
+use SL::DB::CustomVariableConfig;
+use SL::DB::PaymentTerm;
+
+use parent qw(SL::Controller::CsvImport::Base);
+
+use Rose::Object::MakeMethods::Generic
+(
+ 'scalar --get_set_init' => [ qw(table languages_by businesses_by) ],
+);
+
+sub init_table {
+  my ($self) = @_;
+  $self->table($self->controller->profile->get('table') eq 'customer' ? 'customer' : 'vendor');
+}
+
+sub init_class {
+  my ($self) = @_;
+  $self->class('SL::DB::' . ucfirst($self->table));
+}
+
+sub init_all_cvar_configs {
+  my ($self) = @_;
+
+  return SL::DB::Manager::CustomVariableConfig->get_all(where => [ module => 'CT' ]);
+}
+
+sub init_businesses_by {
+  my ($self) = @_;
+
+  return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ SL::DB::Manager::Business->get_all } } ) } qw(id description) };
+}
+
+sub init_languages_by {
+  my ($self) = @_;
+
+  return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $self->all_languages } } ) } qw(id description article_code) };
+}
+
+sub check_objects {
+  my ($self) = @_;
+
+  my $numbercolumn  = $self->controller->profile->get('table') . "number";
+  my %vcs_by_number = map { ( $_->$numbercolumn => 1 ) } @{ $self->existing_objects };
+
+  foreach my $entry (@{ $self->controller->data }) {
+    my $object = $entry->{object};
+
+    $self->check_name($entry);
+    $self->check_language($entry);
+    $self->check_business($entry);
+    $self->check_payment($entry);
+    $self->handle_cvars($entry);
+
+    next if @{ $entry->{errors} };
+
+    if ($vcs_by_number{ $object->$numbercolumn }) {
+      $entry->{object}->$numbercolumn('####');
+    } else {
+      $vcs_by_number{ $object->$numbercolumn } = $object;
+    }
+  }
+
+  $self->add_columns(map { "${_}_id" } grep { exists $self->controller->data->[0]->{raw_data}->{$_} } qw(language business payment));
+  $self->add_cvar_raw_data_columns;
+}
+
+sub check_duplicates {
+  my ($self, %params) = @_;
+
+  my $normalizer = sub { my $name = $_[0]; $name =~ s/[\s,\.\-]//g; return $name; };
+
+  my %by_name;
+  if ('check_db' eq $self->controller->profile->get('duplicates')) {
+    %by_name = map { ( $normalizer->($_->name) => 'db' ) } @{ $self->existing_objects };
+  }
+
+  foreach my $entry (@{ $self->controller->data }) {
+    next if @{ $entry->{errors} };
+
+    my $name = $normalizer->($entry->{object}->name);
+    if (!$by_name{$name}) {
+      $by_name{$name} = 'csv';
+
+    } else {
+      push @{ $entry->{errors} }, $by_name{$name} eq 'db' ? $::locale->text('Duplicate in database') : $::locale->text('Duplicate in CSV file');
+    }
+  }
+}
+
+sub check_name {
+  my ($self, $entry) = @_;
+
+  my $name =  $entry->{object}->name;
+  $name    =~ s/^\s+//;
+  $name    =~ s/\s+$//;
+
+  return 1 if $name;
+
+  push @{ $entry->{errors} }, $::locale->text('Error: Name missing');
+  return 0;
+}
+
+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);
+  }
+
+  return 1;
+}
+
+sub check_business {
+  my ($self, $entry) = @_;
+
+  my $object = $entry->{object};
+
+  # Check whether or not business ID is valid.
+  if ($object->business_id && !$self->businesss_by->{id}->{ $object->business_id }) {
+    push @{ $entry->{errors} }, $::locale->text('Error: Invalid business');
+    return 0;
+  }
+
+  # Map name to ID if given.
+  if (!$object->business_id && $entry->{raw_data}->{business}) {
+    my $business = $self->businesses_by->{description}->{ $entry->{raw_data}->{business} };
+
+    if (!$business) {
+      push @{ $entry->{errors} }, $::locale->text('Error: Invalid business');
+      return 0;
+    }
+
+    $object->business_id($business->id);
+  }
+
+  return 1;
+}
+
+sub save_objects {
+  my ($self, %params) = @_;
+
+  my $numbercolumn   = $self->table . 'number';
+  my $with_number    = [ grep { $_->{object}->$numbercolumn ne '####' } @{ $self->controller->data } ];
+  my $without_number = [ grep { $_->{object}->$numbercolumn eq '####' } @{ $self->controller->data } ];
+
+  map { $_->{object}->$numbercolumn('') } @{ $without_number };
+
+  $self->SUPER::save_objects(data => $with_number);
+  $self->SUPER::save_objects(data => $without_number);
+}
+
+sub field_lengths {
+  return ( name           => 75,
+           department_1   => 75,
+           department_2   => 75,
+           street         => 75,
+           zipcode        => 10,
+           city           => 75,
+           country        => 75,
+           contact        => 75,
+           phone          => 30,
+           fax            => 30,
+           account_number => 15,
+           bank_code      => 10,
+           language       => 5,
+           username       => 50,
+           ustid          => 14,
+           iban           => 100,
+           bic            => 100,
+         );
+}
+
+sub init_profile {
+  my ($self) = @_;
+
+  my $profile = $self->SUPER::init_profile;
+  delete @{$profile}{qw(business datevexport language payment salesman salesman_id taxincluded terms)};
+
+  return $profile;
+}
+
+sub setup_displayable_columns {
+  my ($self) = @_;
+
+  $self->SUPER::setup_displayable_columns;
+  $self->add_cvar_columns_to_displayable_columns;
+
+  $self->add_displayable_columns({ name => 'account_number',    description => $::locale->text('Account Number')                  },
+                                 { name => 'bank',              description => $::locale->text('Bank')                            },
+                                 { name => 'bank_code',         description => $::locale->text('Bank Code')                       },
+                                 { name => 'bcc',               description => $::locale->text('Bcc')                             },
+                                 { name => 'bic',               description => $::locale->text('BIC')                             },
+                                 { name => 'business_id',       description => $::locale->text('Business type (database ID)')     },
+                                 { name => 'business',          description => $::locale->text('Business type (name)')            },
+                                 { name => 'c_vendor_id',       description => $::locale->text('our vendor number at customer')   },
+                                 { name => 'cc',                description => $::locale->text('Cc')                              },
+                                 { name => 'city',              description => $::locale->text('City')                            },
+                                 { name => 'contact',           description => $::locale->text('Contact')                         },
+                                 { name => 'country',           description => $::locale->text('Country')                         },
+                                 { name => 'creditlimit',       description => $::locale->text('Credit Limit')                    },
+                                 { name => 'customernumber',    description => $::locale->text('Customer Number')                 },
+                                 { name => 'department_1',      description => $::locale->text('Department 1')                    },
+                                 { name => 'department_2',      description => $::locale->text('Department 2')                    },
+                                 { name => 'direct_debit',      description => $::locale->text('direct debit')                    },
+                                 { name => 'discount',          description => $::locale->text('Discount')                        },
+                                 { name => 'email',             description => $::locale->text('E-mail')                          },
+                                 { name => 'fax',               description => $::locale->text('Fax')                             },
+                                 { name => 'greeting',          description => $::locale->text('Greeting')                        },
+                                 { name => 'homepage',          description => $::locale->text('Homepage')                        },
+                                 { name => 'iban',              description => $::locale->text('IBAN')                            },
+                                 { name => 'klass',             description => $::locale->text('Preisklasse')                     },
+                                 { name => 'language_id',       description => $::locale->text('Language (database ID)')          },
+                                 { name => 'language',          description => $::locale->text('Language (name)')                 },
+                                 { name => 'name',              description => $::locale->text('Name')                            },
+                                 { name => 'notes',             description => $::locale->text('Notes')                           },
+                                 { name => 'obsolete',          description => $::locale->text('Obsolete')                        },
+                                 { name => 'payment_id',        description => $::locale->text('Payment terms (database ID)')     },
+                                 { name => 'payment',           description => $::locale->text('Payment terms (name)')            },
+                                 { name => 'phone',             description => $::locale->text('Phone')                           },
+                                 { name => 'street',            description => $::locale->text('Street')                          },
+                                 { name => 'taxnumber',         description => $::locale->text('Tax Number / SSN')                },
+                                 { name => 'taxzone_id',        description => $::locale->text('Steuersatz')                      },
+                                 { name => 'user_password',     description => $::locale->text('Password')                        },
+                                 { name => 'username',          description => $::locale->text('Username')                        },
+                                 { name => 'ustid',             description => $::locale->text('sales tax identification number') },
+                                 { name => 'zipcode',           description => $::locale->text('Zipcode')                         },
+                                );
+}
+
+# TODO:
+# salesman_id -- Kunden mit Typ 'Verkäufer', falls $::vertreter an ist, ansonsten Employees
+
+1;
diff --git a/SL/Controller/CsvImport/Part.pm b/SL/Controller/CsvImport/Part.pm
new file mode 100644 (file)
index 0000000..5ae7bdd
--- /dev/null
@@ -0,0 +1,465 @@
+package SL::Controller::CsvImport::Part;
+
+use strict;
+
+use SL::Helper::Csv;
+
+use SL::DB::Buchungsgruppe;
+use SL::DB::CustomVariable;
+use SL::DB::CustomVariableConfig;
+use SL::DB::PartsGroup;
+use SL::DB::PaymentTerm;
+use SL::DB::PriceFactor;
+use SL::DB::Pricegroup;
+use SL::DB::Price;
+use SL::DB::Translation;
+use SL::DB::Unit;
+
+use parent qw(SL::Controller::CsvImport::Base);
+
+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
+                                 translation_columns all_pricegroups) ],
+);
+
+sub init_class {
+  my ($self) = @_;
+  $self->class('SL::DB::Part');
+}
+
+sub init_bg_by {
+  my ($self) = @_;
+
+  my $all_bg = SL::DB::Manager::Buchungsgruppe->get_all;
+  return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $all_bg } } ) } qw(id description) };
+}
+
+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_partsgroups_by {
+  my ($self) = @_;
+
+  my $all_partsgroups = SL::DB::Manager::PartsGroup->get_all;
+  return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $all_partsgroups } } ) } qw(id partsgroup) };
+}
+
+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_parts_by {
+  my ($self) = @_;
+
+  my $parts_by = { id         => { map { ( $_->id => $_ ) } grep { !$_->assembly } @{ $self->existing_objects } },
+                   partnumber => { part    => { },
+                                   service => { } } };
+
+  foreach my $part (@{ $self->existing_objects }) {
+    next if $part->assembly;
+    $parts_by->{partnumber}->{ $part->type }->{ $part->partnumber } = $part;
+  }
+
+  return $parts_by;
+}
+
+sub init_all_pricegroups {
+  my ($self) = @_;
+
+  return SL::DB::Manager::Pricegroup->get_all(sort => 'id');
+}
+
+sub init_settings {
+  my ($self) = @_;
+
+  return { map { ( $_ => $self->controller->profile->get($_) ) } qw(apply_buchungsgruppe default_buchungsgruppe article_number_policy
+                                                                    sellprice_places sellprice_adjustment sellprice_adjustment_type
+                                                                    shoparticle_if_missing parts_type) };
+}
+
+sub init_all_cvar_configs {
+  my ($self) = @_;
+
+  return SL::DB::Manager::CustomVariableConfig->get_all(where => [ module => 'IC' ]);
+}
+
+sub init_translation_columns {
+  my ($self) = @_;
+
+  return [ map { ("description_" . $_->article_code, "notes_" . $_->article_code) } (@{ $self->all_languages }) ];
+}
+
+sub check_objects {
+  my ($self) = @_;
+
+  return unless @{ $self->controller->data };
+
+  $self->makemodel_columns({});
+
+  foreach my $entry (@{ $self->controller->data }) {
+    $self->check_buchungsgruppe($entry);
+    $self->check_type($entry);
+    $self->check_unit($entry);
+    $self->check_price_factor($entry);
+    $self->check_payment($entry);
+    $self->check_partsgroup($entry);
+    $self->handle_pricegroups($entry);
+    $self->check_existing($entry) unless @{ $entry->{errors} };
+    $self->handle_prices($entry) if $self->settings->{sellprice_adjustment};
+    $self->handle_shoparticle($entry);
+    $self->handle_translations($entry);
+    $self->handle_cvars($entry);
+    $self->handle_makemodel($entry);
+    $self->set_various_fields($entry);
+  }
+
+  $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(qw(shop)) if $self->settings->{shoparticle_if_missing};
+  $self->add_cvar_raw_data_columns;
+  map { $self->add_raw_data_columns("pricegroup_${_}") } (1..scalar(@{ $self->all_pricegroups }));
+  map { $self->add_raw_data_columns($_) if exists $self->controller->data->[0]->{raw_data}->{$_} } @{ $self->translation_columns };
+  map { $self->add_raw_data_columns("make_${_}", "model_${_}") } sort { $a <=> $b } keys %{ $self->makemodel_columns };
+}
+
+sub check_duplicates {
+  my ($self, %params) = @_;
+
+  my $normalizer = sub { my $name = $_[0]; $name =~ s/[\s,\.\-]//g; return $name; };
+  my $name_maker = sub { return $normalizer->($_[0]->description) };
+
+  my %by_name;
+  if ('check_db' eq $self->controller->profile->get('duplicates')) {
+    %by_name = map { ( $name_maker->($_) => 'db' ) } @{ $self->existing_objects };
+  }
+
+  foreach my $entry (@{ $self->controller->data }) {
+    next if @{ $entry->{errors} };
+
+    my $name = $name_maker->($entry->{object});
+
+    if (!$by_name{ $name }) {
+      $by_name{ $name } = 'csv';
+
+    } else {
+      push @{ $entry->{errors} }, $by_name{ $name } eq 'db' ? $::locale->text('Duplicate in database') : $::locale->text('Duplicate in CSV file');
+    }
+  }
+}
+
+sub check_buchungsgruppe {
+  my ($self, $entry) = @_;
+
+  my $object = $entry->{object};
+
+  # Check Buchungsgruppe
+
+  # Store and verify default ID.
+  my $default_id = $self->settings->{default_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');
+
+  # 2. Use supplied ID if valid
+  $object->buchungsgruppen_id(undef) if $object->buchungsgruppen_id && !$self->bg_by->{id}->{ $object->buchungsgruppen_id };
+
+  # 3. Look up name if supplied.
+  if (!$object->buchungsgruppen_id) {
+    my $bg = $self->bg_by->{description}->{ $entry->{raw_data}->{buchungsgruppe} };
+    $object->buchungsgruppen_id($bg->id) if $bg;
+  }
+
+  # 4. Use default ID if not valid.
+  $object->buchungsgruppen_id($default_id) if !$object->buchungsgruppen_id && $default_id && ($self->settings->{apply_buchungsgruppe} eq 'missing');
+
+  return 1 if $object->buchungsgruppen_id;
+
+  push @{ $entry->{errors} }, $::locale->text('Error: Buchungsgruppe missing or invalid');
+  return 0;
+}
+
+sub check_existing {
+  my ($self, $entry) = @_;
+
+  my $object = $entry->{object};
+
+  $entry->{part} = $self->parts_by->{partnumber}->{ $object->type }->{ $object->partnumber };
+
+  if ($self->settings->{article_number_policy} eq 'update_prices') {
+    if ($entry->{part}) {
+      map { $entry->{part}->$_( $object->$_ ) } qw(sellprice listprice lastcost prices);
+      push @{ $entry->{information} }, $::locale->text('Updating prices of existing entry in database');
+      $entry->{object_to_save} = $entry->{part};
+    }
+
+  } else {
+    $object->partnumber('####') if $entry->{part};
+  }
+}
+
+sub handle_prices {
+  my ($self, $entry) = @_;
+
+  foreach my $column (qw(sellprice listprice lastcost)) {
+    next unless $self->controller->headers->{used}->{ $column };
+
+    my $adjustment = $self->settings->{sellprice_adjustment};
+    my $value      = $entry->{object}->$column;
+
+    $value = $self->settings->{sellprice_adjustment_type} eq 'percent' ? $value * (100 + $adjustment) / 100 : $value + $adjustment;
+    $entry->{object}->$column($::form->round_amount($value, $self->settings->{sellprice_places}));
+  }
+}
+
+sub handle_shoparticle {
+  my ($self, $entry) = @_;
+
+  $entry->{object}->shop(1) if $self->settings->{shoparticle_if_missing} && !$self->controller->headers->{used}->{shop};
+}
+
+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, income_accno_id_0 => 1, expense_accno_id_0 => 1);
+
+  my $type = $self->settings->{parts_type};
+  if ($type eq 'mixed') {
+    $type = $entry->{raw_data}->{type} =~ m/^p/i ? 'part'
+          : $entry->{raw_data}->{type} =~ m/^s/i ? 'service'
+          :                                        undef;
+  }
+
+  $entry->{object}->income_accno_id(  $bg->income_accno_id_0 );
+  $entry->{object}->expense_accno_id( $bg->expense_accno_id_0 );
+
+  if ($type eq 'part') {
+    $entry->{object}->inventory_accno_id( $bg->inventory_accno_id );
+
+  } elsif ($type ne 'service') {
+    push @{ $entry->{errors} }, $::locale->text('Error: Invalid part type');
+    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 name to ID if given.
+  if (!$object->price_factor_id && $entry->{raw_data}->{price_factor}) {
+    my $pf = $self->price_factors_by->{description}->{ $entry->{raw_data}->{price_factor} };
+
+    if (!$pf) {
+      push @{ $entry->{errors} }, $::locale->text('Error: Invalid price factor');
+      return 0;
+    }
+
+    $object->price_factor_id($pf->id);
+  }
+
+  return 1;
+}
+
+sub check_partsgroup {
+  my ($self, $entry) = @_;
+
+  my $object = $entry->{object};
+
+  # Check whether or not part group ID is valid.
+  if ($object->partsgroup_id && !$self->partsgroups_by->{id}->{ $object->partsgroup_id }) {
+    push @{ $entry->{errors} }, $::locale->text('Error: Invalid parts group');
+    return 0;
+  }
+
+  # Map name to ID if given.
+  if (!$object->partsgroup_id && $entry->{raw_data}->{partsgroup}) {
+    my $pg = $self->partsgroups_by->{partsgroup}->{ $entry->{raw_data}->{partsgroup} };
+
+    if (!$pg) {
+      push @{ $entry->{errors} }, $::locale->text('Error: Invalid parts group');
+      return 0;
+    }
+
+    $object->partsgroup_id($pg->id);
+  }
+
+  return 1;
+}
+
+sub check_unit {
+  my ($self, $entry) = @_;
+
+  my $object = $entry->{object};
+
+  # Check whether or unit is valid.
+  if (!$self->units_by->{name}->{ $object->unit }) {
+    push @{ $entry->{errors} }, $::locale->text('Error: Unit missing or invalid');
+    return 0;
+  }
+
+  return 1;
+}
+
+sub handle_translations {
+  my ($self, $entry) = @_;
+
+  my @translations;
+  foreach my $language (@{ $self->all_languages }) {
+    my ($desc, $notes) = @{ $entry->{raw_data} }{ "description_" . $language->article_code, "notes_" . $language->article_code };
+    next unless $desc || $notes;
+
+    push @translations, SL::DB::Translation->new(language_id     => $language->id,
+                                                 translation     => $desc,
+                                                 longdescription => $notes);
+  }
+
+  $entry->{object}->translations(\@translations);
+}
+
+sub handle_pricegroups {
+  my ($self, $entry) = @_;
+
+  my @prices;
+  my $idx = 0;
+  foreach my $pricegroup (@{ $self->all_pricegroups }) {
+    $idx++;
+    my $sellprice = $entry->{raw_data}->{"pricegroup_${idx}"};
+    next if $sellprice eq '';
+
+    push @prices, SL::DB::Price->new(pricegroup_id => $pricegroup->id,
+                                     price         => $::form->parse_amount(\%::myconfig, $sellprice));
+  }
+
+  $entry->{object}->prices(\@prices);
+}
+
+sub handle_makemodel {
+  my ($self, $entry) = @_;
+
+  my @makemodels;
+  foreach my $idx (map { substr $_, 5 } grep { m/^make_\d+$/ && $entry->{raw_data}->{$_} } keys %{ $entry->{raw_data} }) {
+    my $vendor = $entry->{raw_data}->{"make_${idx}"};
+    $vendor    = $self->vc_by->{id}->               { $vendor }
+              || $self->vc_by->{number}->{vendors}->{ $vendor }
+              || $self->vc_by->{name}->  {vendors}->{ $vendor };
+
+    if (ref($vendor) ne 'SL::DB::Vendor') {
+      push @{ $entry->{errors} }, $::locale->text('Error: Invalid vendor in column make_#1', $idx);
+
+    } else {
+      push @makemodels, SL::DB::MakeModel->new(make  => $vendor->id,
+                                               model => $entry->{raw_data}->{"model_${idx}"});
+      $self->makemodel_columns->{$idx}    = 1;
+      $entry->{raw_data}->{"make_${idx}"} = $vendor->name;
+    }
+  }
+
+  $entry->{object}->makemodels(\@makemodels);
+  $entry->{object}->makemodel(scalar(@makemodels) ? 1 : 0);
+}
+
+sub set_various_fields {
+  my ($self, $entry) = @_;
+
+  $entry->{object}->priceupdate(DateTime->now_local);
+}
+
+sub init_profile {
+  my ($self) = @_;
+
+  my $profile = $self->SUPER::init_profile;
+  delete @{$profile}{qw(alternate assembly bom expense_accno_id income_accno_id inventory_accno_id makemodel priceupdate stockable type)};
+
+  return $profile;
+}
+
+sub save_objects {
+  my ($self, %params) = @_;
+
+  my $with_number    = [ grep { $_->{object}->partnumber ne '####' } @{ $self->controller->data } ];
+  my $without_number = [ grep { $_->{object}->partnumber eq '####' } @{ $self->controller->data } ];
+
+  map { $_->{object}->partnumber('') } @{ $without_number };
+
+  $self->SUPER::save_objects(data => $with_number);
+  $self->SUPER::save_objects(data => $without_number);
+}
+
+sub setup_displayable_columns {
+  my ($self) = @_;
+
+  $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('Buchungsgruppe (database ID)')  },
+                                 { name => 'buchungsgruppe',     description => $::locale->text('Buchungsgruppe (name)')         },
+                                 { name => 'description',        description => $::locale->text('Description')                   },
+                                 { name => 'drawing',            description => $::locale->text('Drawing')                       },
+                                 { name => 'ean',                description => $::locale->text('EAN')                           },
+                                 { name => 'formel',             description => $::locale->text('Formula')                       },
+                                 { name => 'gv',                 description => $::locale->text('Business Volume')               },
+                                 { name => 'has_sernumber',      description => $::locale->text('Has serial number')             },
+                                 { name => 'image',              description => $::locale->text('Image')                         },
+                                 { name => 'lastcost',           description => $::locale->text('Last Cost')                     },
+                                 { name => 'listprice',          description => $::locale->text('List Price')                    },
+                                 { name => 'make_X',             description => $::locale->text('Make (with X being a number)')  },
+                                 { name => 'microfiche',         description => $::locale->text('Microfiche')                    },
+                                 { name => 'model_X',            description => $::locale->text('Model (with X being a number)') },
+                                 { name => 'not_discountable',   description => $::locale->text('Not Discountable')              },
+                                 { name => 'notes',              description => $::locale->text('Notes')                         },
+                                 { name => 'obsolete',           description => $::locale->text('Obsolete')                      },
+                                 { name => 'onhand',             description => $::locale->text('On Hand')                       },
+                                 { name => 'partnumber',         description => $::locale->text('Part Number')                   },
+                                 { name => 'partsgroup_id',      description => $::locale->text('Partsgroup (database ID)')      },
+                                 { name => 'partsgroup',         description => $::locale->text('Partsgroup (name)')             },
+                                 { name => 'payment_id',         description => $::locale->text('Payment terms (database ID)')   },
+                                 { name => 'payment',            description => $::locale->text('Payment terms (name)')          },
+                                 { name => 'price_factor_id',    description => $::locale->text('Price factor (database ID)')    },
+                                 { 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('Shopartikel')                   },
+                                 { name => 'type',               description => $::locale->text('Article type (see below)')      },
+                                 { name => 'unit',               description => $::locale->text('Unit')                          },
+                                 { name => 've',                 description => $::locale->text('Verrechnungseinheit')           },
+                                 { name => 'weight',             description => $::locale->text('Weight')                        },
+                                );
+
+  foreach my $language (@{ $self->all_languages }) {
+    $self->add_displayable_columns({ name        => 'description_' . $language->article_code,
+                                     description => $::locale->text('Description (translation for #1)', $language->description) },
+                                   { name        => 'notes_' . $language->article_code,
+                                     description => $::locale->text('Notes (translation for #1)', $language->description) });
+  }
+
+  my $idx = 0;
+  foreach my $pricegroup (@{ $self->all_pricegroups }) {
+    $idx++;
+    $self->add_displayable_columns({ name        => 'pricegroup_' . $idx,
+                                     description => $::locale->text("Sellprice for price group '#1'", $pricegroup->pricegroup) });
+  }
+}
+
+1;
diff --git a/SL/Controller/CsvImport/Shipto.pm b/SL/Controller/CsvImport/Shipto.pm
new file mode 100644 (file)
index 0000000..f46c09d
--- /dev/null
@@ -0,0 +1,107 @@
+package SL::Controller::CsvImport::Shipto;
+
+use strict;
+
+use SL::Helper::Csv;
+
+use parent qw(SL::Controller::CsvImport::Base);
+
+use Rose::Object::MakeMethods::Generic
+(
+ scalar => [ qw(table) ],
+);
+
+sub init_class {
+  my ($self) = @_;
+  $self->class('SL::DB::Shipto');
+}
+
+sub check_objects {
+  my ($self) = @_;
+
+  foreach my $entry (@{ $self->controller->data }) {
+    $self->check_vc($entry, 'trans_id');
+    $entry->{object}->module('CT');
+  }
+
+  $self->add_info_columns({ header => $::locale->text('Customer/Vendor'), method => 'vc_name' });
+}
+
+sub check_duplicates {
+  my ($self, %params) = @_;
+
+  my $normalizer = sub { my $name = $_[0]; $name =~ s/[\s,\.\-]//g; return $name; };
+  my $name_maker = sub { return $normalizer->($_[0]->shiptoname) . '--' . $normalizer->($_[0]->shiptostreet) };
+
+  my %by_id_and_name;
+  if ('check_db' eq $self->controller->profile->get('duplicates')) {
+    foreach my $type (qw(customers vendors)) {
+      foreach my $vc (@{ $self->all_vc->{$type} }) {
+        $by_id_and_name{ $vc->id } = { map { ( $name_maker->($_) => 'db' ) } @{ $vc->shipto } };
+      }
+    }
+  }
+
+  foreach my $entry (@{ $self->controller->data }) {
+    next if @{ $entry->{errors} };
+
+    my $name = $name_maker->($entry->{object});
+
+    $by_id_and_name{ $entry->{vc}->id } ||= { };
+    if (!$by_id_and_name{ $entry->{vc}->id }->{ $name }) {
+      $by_id_and_name{ $entry->{vc}->id }->{ $name } = 'csv';
+
+    } else {
+      push @{ $entry->{errors} }, $by_id_and_name{ $entry->{vc}->id }->{ $name } eq 'db' ? $::locale->text('Duplicate in database') : $::locale->text('Duplicate in CSV file');
+    }
+  }
+}
+
+sub field_lengths {
+  return ( shiptoname         => 75,
+           shiptodepartment_1 => 75,
+           shiptodepartment_2 => 75,
+           shiptostreet       => 75,
+           shiptozipcode      => 75,
+           shiptocity         => 75,
+           shiptocountry      => 75,
+           shiptocontact      => 75,
+           shiptophone        => 30,
+           shiptofax          => 30,
+         );
+}
+
+sub init_profile {
+  my ($self) = @_;
+
+  my $profile = $self->SUPER::init_profile;
+  delete @{$profile}{qw(module)};
+
+  return $profile;
+}
+
+sub setup_displayable_columns {
+  my ($self) = @_;
+
+  $self->SUPER::setup_displayable_columns;
+
+  $self->add_displayable_columns({ name => 'shiptocity',         description => $::locale->text('City')                          },
+                                 { name => 'shiptocontact',      description => $::locale->text('Contact')                       },
+                                 { name => 'shiptocountry',      description => $::locale->text('Country')                       },
+                                 { name => 'shiptodepartment_1', description => $::locale->text('Department 1')                  },
+                                 { name => 'shiptodepartment_2', description => $::locale->text('Department 2')                  },
+                                 { name => 'shiptoemail',        description => $::locale->text('E-mail')                        },
+                                 { name => 'shiptofax',          description => $::locale->text('Fax')                           },
+                                 { name => 'shiptoname',         description => $::locale->text('Name')                          },
+                                 { name => 'shiptophone',        description => $::locale->text('Phone')                         },
+                                 { name => 'shiptostreet',       description => $::locale->text('Street')                        },
+                                 { name => 'shiptozipcode',      description => $::locale->text('Zipcode')                       },
+                                 { name => 'trans_id',           description => $::locale->text('Customer/Vendor (database ID)') },
+                                 { name => 'customer',           description => $::locale->text('Customer (name)')               },
+                                 { name => 'customernumber',     description => $::locale->text('Customer Number')               },
+                                 { name => 'vendor',             description => $::locale->text('Vendor (name)')                 },
+                                 { name => 'vendornumber',       description => $::locale->text('Vendor Number')                 },
+                                );
+}
+
+1;
diff --git a/SL/DB/CsvImportProfile.pm b/SL/DB/CsvImportProfile.pm
new file mode 100644 (file)
index 0000000..ea7b4fe
--- /dev/null
@@ -0,0 +1,122 @@
+package SL::DB::CsvImportProfile;
+
+use strict;
+
+use List::Util qw(first);
+
+use SL::DB::MetaSetup::CsvImportProfile;
+
+__PACKAGE__->meta->add_relationship(
+  settings => {
+    type       => 'one to many',
+    class      => 'SL::DB::CsvImportProfileSetting',
+    column_map => { id      => 'csv_import_profile_id' },
+  },
+);
+
+__PACKAGE__->meta->initialize;
+
+__PACKAGE__->meta->make_manager_class;
+
+__PACKAGE__->before_save('_before_save_unset_default_on_others');
+
+#
+# public functions
+#
+
+sub new_with_default {
+  my ($class, $type) = @_;
+
+  return $class->new(type => $type)->set_defaults;
+}
+
+sub set_defaults {
+  my ($self) = @_;
+
+  $self->_set_defaults(sep_char     => ',',
+                       quote_char   => '"',
+                       escape_char  => '"',
+                       charset      => 'CP850',
+                       numberformat => $::myconfig{numberformat},
+                       duplicates   => 'no_check',
+                      );
+
+  if ($self->type eq 'parts') {
+    my $bugru = SL::DB::Manager::Buchungsgruppe->find_by(description => { like => 'Standard%19%' });
+
+    $self->_set_defaults(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',
+                        );
+  } else {
+    $self->_set_defaults(table => 'customer');
+  }
+
+  return $self;
+}
+
+sub set {
+  my ($self, %params) = @_;
+
+  while (my ($key, $value) = each %params) {
+    my $setting = $self->_get_setting($key);
+
+    if (!$setting) {
+      $setting = SL::DB::CsvImportProfileSetting->new(key => $key);
+      $self->settings(@{ $self->settings || [] }, $setting);
+    }
+
+    $setting->value($value);
+  }
+
+  return $self;
+}
+
+sub get {
+  my ($self, $key, $default) = @_;
+
+  my $setting = $self->_get_setting($key);
+  return $setting ? $setting->value : $default;
+}
+
+sub _set_defaults {
+  my ($self, %params) = @_;
+
+  while (my ($key, $value) = each %params) {
+    $self->settings(@{ $self->settings || [] }, { key => $key, value => $value }) if !$self->_get_setting($key);
+  }
+
+  return $self;
+}
+
+#
+# hooks
+#
+
+sub _before_save_unset_default_on_others {
+  my ($self) = @_;
+
+  if ($self->is_default) {
+    SL::DB::Manager::CsvImportProfile->update_all(set   => { is_default => 0 },
+                                                  where => [ type       => $self->type,
+                                                             '!id'      => $self->id ]);
+  }
+
+  return 1;
+}
+
+#
+# helper functions
+#
+
+sub _get_setting {
+  my ($self, $key) = @_;
+  return first { $_->key eq $key } @{ $self->settings || [] };
+}
+
+1;
diff --git a/SL/DB/CsvImportProfileSetting.pm b/SL/DB/CsvImportProfileSetting.pm
new file mode 100644 (file)
index 0000000..6da5b34
--- /dev/null
@@ -0,0 +1,13 @@
+# This file has been auto-generated only because it didn't exist.
+# Feel free to modify it at will; it will not be overwritten automatically.
+
+package SL::DB::CsvImportProfileSetting;
+
+use strict;
+
+use SL::DB::MetaSetup::CsvImportProfileSetting;
+
+# Creates get_all, get_all_count, get_all_iterator, delete_all and update_all.
+__PACKAGE__->meta->make_manager_class;
+
+1;
index 27683b3..186ad97 100644 (file)
@@ -3,6 +3,8 @@ package SL::DB::Customer;
 use strict;
 
 use SL::DB::MetaSetup::Customer;
+use SL::DB::Manager::Customer;
+use SL::DB::Helper::TransNumberGenerator;
 
 use SL::DB::VC;
 
@@ -12,18 +14,38 @@ __PACKAGE__->meta->add_relationship(
     class        => 'SL::DB::Shipto',
     column_map   => { id      => 'trans_id' },
     manager_args => { sort_by => 'lower(shipto.shiptoname)' },
-    query_args   => [ 'module' => 'CT' ],
+    query_args   => [ module   => 'CT' ],
+  },
+  contacts => {
+    type         => 'one to many',
+    class        => 'SL::DB::Contact',
+    column_map   => { id      => 'cp_cv_id' },
+    manager_args => { sort_by => 'lower(contacts.cp_name)' },
   },
   business => {
     type         => 'one to one',
     class        => 'SL::DB::Business',
     column_map   => { business_id => 'id' },
   },
+  custom_variables => {
+    type           => 'one to many',
+    class          => 'SL::DB::CustomVariable',
+    column_map     => { id => 'trans_id' },
+    query_args     => [ config_id => [ \"(SELECT custom_variable_configs.id FROM custom_variable_configs WHERE custom_variable_configs.module = 'CT')" ] ],
+  },
 );
 
-__PACKAGE__->meta->make_manager_class;
 __PACKAGE__->meta->initialize;
 
+__PACKAGE__->before_save('_before_save_set_customernumber');
+
+sub _before_save_set_customernumber {
+  my ($self) = @_;
+
+  $self->create_trans_number if $self->customernumber eq '';
+  return 1;
+}
+
 sub short_address {
   my ($self) = @_;
 
index c030bed..54b4b49 100644 (file)
@@ -13,6 +13,8 @@ use SL::DB::Buchungsgruppe;
 use SL::DB::Business;
 use SL::DB::Chart;
 use SL::DB::Contact;
+use SL::DB::CsvImportProfile;
+use SL::DB::CsvImportProfileSetting;
 use SL::DB::CustomVariable;
 use SL::DB::CustomVariableConfig;
 use SL::DB::CustomVariableValidity;
index 4fe4ac9..7f281e3 100644 (file)
@@ -39,6 +39,8 @@ my %lxoffice_package_names = (
   bank_accounts                  => 'bank_account',
   buchungsgruppen                => 'buchungsgruppe',
   contacts                       => 'contact',
+  csv_import_profiles            => 'csv_import_profile',
+  csv_import_profile_settings    => 'csv_import_profile_setting',
   custom_variable_configs        => 'custom_variable_config',
   custom_variables               => 'custom_variable',
   custom_variables_validity      => 'custom_variable_validity',
index 0505268..f7c7793 100644 (file)
@@ -10,21 +10,30 @@ use List::Util qw(max);
 
 use SL::DB::Default;
 
-my $oe_scoping = sub {
+sub oe_scoping {
   SL::DB::Manager::Order->type_filter($_[0]);
-};
+}
 
-my $do_scoping = sub {
+sub do_scoping {
   SL::DB::Manager::DeliveryOrder->type_filter($_[0]);
-};
-
-my %specs = ( ar                      => { number_column => 'invnumber',                                                             fill_holes_in_range => 1 },
-              sales_quotation         => { number_column => 'quonumber', number_range_column => 'sqnumber',  scoping => $oe_scoping,                          },
-              sales_order             => { number_column => 'ordnumber', number_range_column => 'sonumber',  scoping => $oe_scoping,                          },
-              request_quotation       => { number_column => 'quonumber', number_range_column => 'rfqnumber', scoping => $oe_scoping,                          },
-              purchase_order          => { number_column => 'ordnumber', number_range_column => 'ponumber',  scoping => $oe_scoping,                          },
-              sales_delivery_order    => { number_column => 'donumber',  number_range_column => 'sdonumber', scoping => $do_scoping, fill_holes_in_range => 1 },
-              purchase_delivery_order => { number_column => 'donumber',  number_range_column => 'pdonumber', scoping => $do_scoping, fill_holes_in_range => 1 },
+}
+
+sub parts_scoping {
+  SL::DB::Manager::Part->type_filter($_[0]);
+}
+
+my %specs = ( ar                      => { number_column => 'invnumber',                                                                        fill_holes_in_range => 1 },
+              sales_quotation         => { number_column => 'quonumber',      number_range_column => 'sqnumber',       scoping => \&oe_scoping,                          },
+              sales_order             => { number_column => 'ordnumber',      number_range_column => 'sonumber',       scoping => \&oe_scoping,                          },
+              request_quotation       => { number_column => 'quonumber',      number_range_column => 'rfqnumber',      scoping => \&oe_scoping,                          },
+              purchase_order          => { number_column => 'ordnumber',      number_range_column => 'ponumber',       scoping => \&oe_scoping,                          },
+              sales_delivery_order    => { number_column => 'donumber',       number_range_column => 'sdonumber',      scoping => \&do_scoping, fill_holes_in_range => 1 },
+              purchase_delivery_order => { number_column => 'donumber',       number_range_column => 'pdonumber',      scoping => \&do_scoping, fill_holes_in_range => 1 },
+              customer                => { number_column => 'customernumber', number_range_column => 'customernumber',                                                   },
+              vendor                  => { number_column => 'vendornumber',   number_range_column => 'vendornumber',                                                     },
+              part                    => { number_column => 'partnumber',     number_range_column => 'articlenumber',  scoping => \&parts_scoping                        },
+              service                 => { number_column => 'partnumber',     number_range_column => 'servicenumber',  scoping => \&parts_scoping                        },
+              assembly                => { number_column => 'partnumber',     number_range_column => 'articlenumber',  scoping => \&parts_scoping                        },
             );
 
 sub get_next_trans_number {
diff --git a/SL/DB/Manager/Customer.pm b/SL/DB/Manager/Customer.pm
new file mode 100644 (file)
index 0000000..0d62fe8
--- /dev/null
@@ -0,0 +1,21 @@
+package SL::DB::Manager::Customer;
+
+use strict;
+
+use SL::DB::Helper::Manager;
+use base qw(SL::DB::Helper::Manager);
+
+use SL::DB::Helper::Sorted;
+
+sub object_class { 'SL::DB::Customer' }
+
+__PACKAGE__->make_manager_methods;
+
+sub _sort_spec {
+  return ( default => [ 'name', 1 ],
+           columns => { SIMPLE => 'ALL',
+                        map { ( $_ => "lower(customer.$_)" ) } qw(customernumber vendornumber name contact phone fax email street taxnumber business invnumber ordnumber quonumber)
+                      });
+}
+
+1;
diff --git a/SL/DB/MetaSetup/CsvImportProfile.pm b/SL/DB/MetaSetup/CsvImportProfile.pm
new file mode 100644 (file)
index 0000000..0acc97d
--- /dev/null
@@ -0,0 +1,25 @@
+# This file has been auto-generated. Do not modify it; it will be overwritten
+# by rose_auto_create_model.pl automatically.
+package SL::DB::CsvImportProfile;
+
+use strict;
+
+use base qw(SL::DB::Object);
+
+__PACKAGE__->meta->setup(
+  table   => 'csv_import_profiles',
+
+  columns => [
+    id         => { type => 'serial', not_null => 1 },
+    name       => { type => 'text', not_null => 1 },
+    type       => { type => 'varchar', length => 20, not_null => 1 },
+    is_default => { type => 'boolean', default => 'false' },
+  ],
+
+  primary_key_columns => [ 'id' ],
+
+  unique_key => [ 'name' ],
+);
+
+1;
+;
diff --git a/SL/DB/MetaSetup/CsvImportProfileSetting.pm b/SL/DB/MetaSetup/CsvImportProfileSetting.pm
new file mode 100644 (file)
index 0000000..d1822a3
--- /dev/null
@@ -0,0 +1,32 @@
+# This file has been auto-generated. Do not modify it; it will be overwritten
+# by rose_auto_create_model.pl automatically.
+package SL::DB::CsvImportProfileSetting;
+
+use strict;
+
+use base qw(SL::DB::Object);
+
+__PACKAGE__->meta->setup(
+  table   => 'csv_import_profile_settings',
+
+  columns => [
+    id                    => { type => 'serial', not_null => 1 },
+    csv_import_profile_id => { type => 'integer', not_null => 1 },
+    key                   => { type => 'text', not_null => 1 },
+    value                 => { type => 'text' },
+  ],
+
+  primary_key_columns => [ 'id' ],
+
+  unique_key => [ 'csv_import_profile_id', 'key' ],
+
+  foreign_keys => [
+    csv_import_profile => {
+      class       => 'SL::DB::CsvImportProfile',
+      key_columns => { csv_import_profile_id => 'id' },
+    },
+  ],
+);
+
+1;
+;
index e479514..ac3b0a2 100644 (file)
@@ -44,11 +44,10 @@ sub run_hooks {
 
   foreach my $sub (@{ ( $hooks{$when} || { })->{ ref($object) } || [ ] }) {
     my $result = ref($sub) eq 'CODE' ? $sub->($object, @args) : $object->call_sub($sub, @args);
-    die SL::X::DBHookError->new(
-      hook   => (ref($sub) eq 'CODE' ? '<anonymous sub>' : $sub),
-      when   => $when,
-      object => $object,
-    ) if !$result;
+    die SL::X::DBHookError->new(when   => $when,
+                                hook   => (ref($sub) eq 'CODE' ? '<anonymous sub>' : $sub),
+                                object => $object)
+      if !$result;
   }
 }
 
@@ -122,8 +121,8 @@ C<after_xyz> function names above.
 An exception of C<SL::X::DBHookError> is thrown if any of the hooks
 returns a falsish value.
 
-This function is supposed to be called by L<Rose::DB::Object/load>,
-L<Rose::DB::Object/save> or L<Rose::DB::Object/delete>.
+This function is supposed to be called by L</SL::DB::Object::load>,
+L</SL::DB::Object::save> or L</SL::DB::Object::delete>.
 
 =back
 
index fd55e02..fd53dd6 100644 (file)
@@ -9,6 +9,7 @@ use SL::DBUtils;
 use SL::DB::MetaSetup::Part;
 use SL::DB::Manager::Part;
 use SL::DB::Chart;
+use SL::DB::Helper::TransNumberGenerator;
 
 __PACKAGE__->meta->add_relationships(
   unit_obj                     => {
@@ -36,10 +37,35 @@ __PACKAGE__->meta->add_relationships(
     class        => 'SL::DB::Price',
     column_map   => { id => 'parts_id' },
   },
+  makemodels     => {
+    type         => 'one to many',
+    class        => 'SL::DB::MakeModel',
+    column_map   => { id => 'parts_id' },
+  },
+  translations   => {
+    type         => 'one to many',
+    class        => 'SL::DB::Translation',
+    column_map   => { id => 'parts_id' },
+  },
+  custom_variables => {
+    type           => 'one to many',
+    class          => 'SL::DB::CustomVariable',
+    column_map     => { id => 'trans_id' },
+    query_args     => [ config_id => [ \"(SELECT custom_variable_configs.id FROM custom_variable_configs WHERE custom_variable_configs.module = 'IC')" ] ],
+  },
 );
 
 __PACKAGE__->meta->initialize;
 
+__PACKAGE__->before_save('_before_save_set_partnumber');
+
+sub _before_save_set_partnumber {
+  my ($self) = @_;
+
+  $self->create_trans_number if $self->partnumber eq '';
+  return 1;
+}
+
 sub is_type {
   my $self = shift;
   my $type  = lc(shift || '');
index 11e23fe..53ac297 100644 (file)
@@ -3,6 +3,7 @@ package SL::DB::Vendor;
 use strict;
 
 use SL::DB::MetaSetup::Vendor;
+use SL::DB::Helper::TransNumberGenerator;
 
 use SL::DB::VC;
 
@@ -12,16 +13,37 @@ __PACKAGE__->meta->add_relationship(
     class        => 'SL::DB::Shipto',
     column_map   => { id      => 'trans_id' },
     manager_args => { sort_by => 'lower(shipto.shiptoname)' },
-    query_args   => [ 'shipto.module' => 'CT' ],
+    query_args   => [ module  => 'CT' ],
+  },
+  contacts => {
+    type         => 'one to many',
+    class        => 'SL::DB::Contact',
+    column_map   => { id      => 'cp_cv_id' },
+    manager_args => { sort_by => 'lower(contacts.cp_name)' },
   },
   business => {
     type         => 'one to one',
     class        => 'SL::DB::Business',
     column_map   => { business_id => 'id' },
   },
+  custom_variables => {
+    type           => 'one to many',
+    class          => 'SL::DB::CustomVariable',
+    column_map     => { id => 'trans_id' },
+    query_args     => [ config_id => [ \"(SELECT custom_variable_configs.id FROM custom_variable_configs WHERE custom_variable_configs.module = 'CT')" ] ],
+  },
 );
 
 __PACKAGE__->meta->make_manager_class;
 __PACKAGE__->meta->initialize;
 
+__PACKAGE__->before_save('_before_save_set_vendornumber');
+
+sub _before_save_set_vendornumber {
+  my ($self) = @_;
+
+  $self->create_trans_number if $self->vendornumber eq '';
+  return 1;
+}
+
 1;
diff --git a/SL/Helper/Csv.pm b/SL/Helper/Csv.pm
new file mode 100644 (file)
index 0000000..3132b28
--- /dev/null
@@ -0,0 +1,408 @@
+package SL::Helper::Csv;
+
+use strict;
+use warnings;
+
+use Carp;
+use IO::File;
+use Params::Validate qw(:all);
+use Text::CSV_XS;
+use Rose::Object::MakeMethods::Generic scalar => [ qw(
+  file encoding sep_char quote_char escape_char header profile class
+  numberformat dateformat ignore_unknown_columns strict_profile _io _csv
+  _objects _parsed _data _errors
+) ];
+
+use SL::Helper::Csv::Dispatcher;
+use SL::Helper::Csv::Error;
+
+# public interface
+
+sub new {
+  my $class  = shift;
+  my %params = validate(@_, {
+    sep_char               => { default => ';' },
+    quote_char             => { default => '"' },
+    escape_char            => { default => '"' },
+    header                 => { type    => ARRAYREF, optional => 1 },
+    profile                => { type    => HASHREF,  optional => 1 },
+    file                   => 1,
+    encoding               => 0,
+    class                  => 0,
+    numberformat           => 0,
+    dateformat             => 0,
+    ignore_unknown_columns => 0,
+    strict_profile         => 0,
+  });
+  my $self = bless {}, $class;
+
+  $self->$_($params{$_}) for keys %params;
+
+  $self->_io(IO::File->new);
+  $self->_csv(Text::CSV_XS->new({
+    binary => 1,
+    sep_char    => $self->sep_char,
+    quote_char  => $self->quote_char,
+    escape_char => $self->escape_char,
+
+  }));
+  $self->_errors([]);
+
+  return $self;
+}
+
+sub parse {
+  my ($self, %params) = @_;
+
+  $self->_open_file;
+  return if ! $self->_check_header;
+  return if ! $self->dispatcher->parse_profile;
+  return if ! $self->_parse_data;
+
+  $self->_parsed(1);
+  return $self;
+}
+
+sub get_data {
+  $_[0]->_data;
+}
+
+sub get_objects {
+  my ($self, %params) = @_;
+  croak 'no class given'   unless $self->class;
+  croak 'must parse first' unless $self->_parsed;
+
+  $self->_make_objects unless $self->_objects;
+  return wantarray ? @{ $self->_objects } : $self->_objects;
+}
+
+sub errors {
+  @{ $_[0]->_errors }
+}
+
+sub check_header {
+  $_[0]->_check_header;
+}
+
+# private stuff
+
+sub _open_file {
+  my ($self, %params) = @_;
+
+  $self->encoding($self->_guess_encoding) if !$self->encoding;
+
+  $self->_io->open($self->file, '<' . $self->_encode_layer)
+    or die "could not open file " . $self->file;
+
+  return $self->_io;
+}
+
+sub _check_header {
+  my ($self, %params) = @_;
+  my $header = $self->header;
+
+  if (! $header) {
+    $header = $self->_csv->getline($self->_io);
+
+    $self->_push_error([
+      $self->_csv->error_input,
+      $self->_csv->error_diag,
+      0,
+    ]) unless $header;
+  }
+
+  return unless $header;
+  return $self->header([ map { lc } @$header ]);
+}
+
+sub _parse_data {
+  my ($self, %params) = @_;
+  my (@data, @errors);
+
+  $self->_csv->column_names(@{ $self->header });
+
+  while (1) {
+    my $row = $self->_csv->getline($self->_io);
+    if ($row) {
+      my %hr;
+      @hr{@{ $self->header }} = @$row;
+      push @data, \%hr;
+    } else {
+      last if $self->_csv->eof;
+      push @errors, [
+        $self->_csv->error_input,
+        $self->_csv->error_diag,
+        $self->_io->input_line_number,
+      ];
+    }
+    last if $self->_csv->eof;
+  }
+
+  $self->_data(\@data);
+  $self->_push_error(@errors);
+
+  return ! @errors;
+}
+
+sub _encode_layer {
+  ':encoding(' . $_[0]->encoding . ')';
+}
+
+sub _make_objects {
+  my ($self, %params) = @_;
+  my @objs;
+
+  eval "require " . $self->class;
+  local $::myconfig{numberformat} = $self->numberformat if $self->numberformat;
+  local $::myconfig{dateformat}   = $self->dateformat   if $self->dateformat;
+
+  for my $line (@{ $self->_data }) {
+    my $tmp_obj = $self->class->new;
+    $self->dispatcher->dispatch($tmp_obj, $line);
+    push @objs, $tmp_obj;
+  }
+
+  $self->_objects(\@objs);
+}
+
+sub dispatcher {
+  my ($self, %params) = @_;
+
+  $self->{_dispatcher} ||= $self->_make_dispatcher;
+}
+
+sub _make_dispatcher {
+  my ($self, %params) = @_;
+
+  die 'need a header to make a dispatcher' unless $self->header;
+
+  return SL::Helper::Csv::Dispatcher->new($self);
+}
+
+sub _guess_encoding {
+  # won't fix
+  'utf-8';
+}
+
+sub _push_error {
+  my ($self, @errors) = @_;
+  my @new_errors = ($self->errors, map { SL::Helper::Csv::Error->new(@$_) } @errors);
+  $self->_errors(\@new_errors);
+}
+
+
+1;
+
+__END__
+
+=encoding utf-8
+
+=head1 NAME
+
+SL::Helper::Csv - take care of csv file uploads
+
+=head1 SYNOPSIS
+
+  use SL::Helper::Csv;
+
+  my $csv = SL::Helper::Csv->new(
+    file        => \$::form->{upload_file},
+    encoding    => 'utf-8', # undef means utf8
+    sep_char    => ',',     # default ';'
+    quote_char  => '\'',    # default '"'
+    escape_char => '"',     # default '"'
+    header      => [qw(id text sellprice word)], # see later
+    profile     => { sellprice => 'sellprice_as_number' },
+    class       => 'SL::DB::CsvLine',   # if present, map lines to this
+  );
+
+  my $status  = $csv->parse;
+  my $hrefs   = $csv->get_data;
+  my @objects = $csv->get_objects;
+
+  my @errors  = $csv->errors;
+
+=head1 DESCRIPTION
+
+See Synopsis.
+
+Text::CSV offeres already good functions to get lines out of a csv file, but in
+most cases you will want those line to be parsed into hashes or even objects,
+so this model just skips ahead and gives you objects.
+
+Its basic assumptions are:
+
+=over 4
+
+=item You do know what you expect to be in that csv file.
+
+This means first and foremost you have knowledge about encoding, number and
+date format, csv parameters such as quoting and separation characters. You also
+know what content will be in that csv and what L<Rose::DB> is responsible for
+it. You provide valid header columns and their mapping to the objects.
+
+=item You do NOT know if the csv provider yields to your expectations.
+
+Stuff that does not work with what you expect should not crash anything, but
+give you a hint what went wrong. As a result, if you remeber to check for
+errors after each step, you should be fine.
+
+=item Data does not make sense. It's just data.
+
+Almost all data imports have some type of constraints. Some data needs to be
+unique, other data needs to be connected to existing data sets. This will not
+happen here. You will receive a plain mapping of the data into the class tree,
+nothing more.
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item C<new> PARAMS
+
+Standard constructor. You can use this to set most of the data.
+
+=item C<parse>
+
+Do the actual work. Will return true ($self actually) if success, undef if not.
+
+=item C<get_objects>
+
+Parse the data into objects and return those.
+
+This method will return list or arrayref depending on context.
+
+=item C<get_data>
+
+Returns an arrayref of the raw lines as hashrefs.
+
+=item C<errors>
+
+Return all errors that came up during parsing. See error handling for detailed
+information.
+
+=back
+
+=head1 PARAMS
+
+=over 4
+
+=item C<file>
+
+The file which contents are to be read. Can be a name of a physical file or a
+scalar ref for memory data.
+
+=item C<encoding>
+
+Encoding of the CSV file. Note that this module does not do any encoding
+guessing. Know what your data is. Defaults to utf-8.
+
+=item C<sep_char>
+
+=item C<quote_char>
+
+=item C<escape_char>
+
+Same as in L<Text::CSV>
+
+=item C<header> \@FIELDS
+
+Can be an array of columns, in this case the first line is not used as a
+header. Empty header fields will be ignored in objects.
+
+=item C<profile> \%ACCESSORS
+
+May be used to map header fields to custom accessors. Example:
+
+  { listprice => listprice_as_number }
+
+In this case C<listprice_as_number> will be used to read in values from the
+C<listprice> column.
+
+In case of a One-To-One relationsship these can also be set over
+relationsships by sparating the steps with a dot (C<.>). This will work:
+
+  { customer => 'customer.name' }
+
+And will result in something like this:
+
+  $obj->customer($obj->meta->relationship('customer')->class->new);
+  $obj->customer->name($csv_line->{customer})
+
+But beware, this will not try to look up anything in the database. You will
+simply receive objects that represent what the profile defined. If some of
+these information are unique, and should be connected to preexisting data, you
+will have to do that for yourself. Since you provided the profile, it is
+assumed you know what to do in this case.
+
+=item C<class>
+
+If present, the line will be handed to the new sub of this class,
+and the return value used instead of the line itself.
+
+=item C<ignore_unknown_columns>
+
+If set, the import will ignore unkown header columns. Useful for lazy imports,
+but deactivated by default.
+
+=item C<strict_profile>
+
+If set, all columns to be parsed must be specified in C<profile>. Every header
+field not listed there will be treated like an unknown column.
+
+=back
+
+=head1 ERROR HANDLING
+
+After parsing a file all errors will be accumulated into C<errors>.
+Each entry is an object with the following attributes:
+
+ raw_input:  offending raw input,
+ code:   Text::CSV error code if Text:CSV signalled an error, 0 else,
+ diag:   error diagnostics,
+ line:   position in line,
+ col:    estimated line in file,
+
+Note that the last entry can be off, but will give an estimate.
+
+=head1 CAVEATS
+
+=over 4
+
+=item *
+
+sep_char, quote_char, and escape_char are passed to Text::CSV on creation.
+Changing them later has no effect currently.
+
+=item *
+
+Encoding errors are not dealt with properly.
+
+=back
+
+=head1 TODO
+
+Dispatch to child objects, like this:
+
+ $csv = SL::Helper::Csv->new(
+   file  => ...
+   class => SL::DB::Part,
+   profile => [
+     makemodel => {
+       make_1  => make,
+       model_1 => model,
+     },
+     makemodel => {
+       make_2  => make,
+       model_2 => model,
+     },
+   ]
+ );
+
+=head1 AUTHOR
+
+Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
+
+=cut
diff --git a/SL/Helper/Csv/Dispatcher.pm b/SL/Helper/Csv/Dispatcher.pm
new file mode 100644 (file)
index 0000000..6375daf
--- /dev/null
@@ -0,0 +1,154 @@
+package SL::Helper::Csv::Dispatcher;
+
+use strict;
+
+use Data::Dumper;
+use Carp;
+use Scalar::Util qw(weaken);
+use Rose::Object::MakeMethods::Generic scalar => [ qw(
+  _specs _errors
+) ];
+
+use SL::Helper::Csv::Error;
+
+sub new {
+  my ($class, $parent) = @_;
+  my $self = bless { }, $class;
+
+  weaken($self->{_csv} = $parent);
+  $self->_errors([]);
+
+  return $self;
+}
+
+sub dispatch {
+  my ($self, $obj, $line) = @_;
+
+  for my $spec (@{ $self->_specs }) {
+    $self->apply($obj, $spec, $line->{$spec->{key}});
+  }
+}
+
+sub apply {
+  my ($self, $obj, $spec, $value) = @_;
+  return unless $value;
+
+  for my $step (@{ $spec->{steps} }) {
+    my ($acc, $class, $index) = @$step;
+    if ($class) {
+
+      # autovifify
+      if (defined $index) {
+        if (! $obj->$acc || !$obj->$acc->[$index]) {
+          my @objects = $obj->$acc;
+          $obj->$acc(@objects, map { $class->new } 0 .. $index - @objects);
+        }
+        $obj = $obj->$acc->[$index];
+      } else {
+        if (! $obj->$acc) {
+          $obj->$acc($class->new);
+        }
+        $obj = $obj->$acc;
+      }
+
+    } else {
+      $obj->$acc($value);
+    }
+  }
+}
+
+sub is_known {
+  my ($self, $col) = @_;
+  return grep { $col eq $_->{key} } $self->_specs;
+}
+
+sub parse_profile {
+  my ($self, %params) = @_;
+
+  my $header  = $self->_csv->header;
+  my $profile = $self->_csv->profile;
+  my @specs;
+
+  for my $col (@$header) {
+    next unless $col;
+    if ($self->_csv->strict_profile) {
+      if (exists $profile->{$col}) {
+        push @specs, $self->make_spec($col, $profile->{$col});
+      } else {
+        $self->unknown_column($col, undef);
+      }
+    } else {
+      push @specs, $self->make_spec($col, $profile->{$col} || $col);
+    }
+  }
+
+  $self->_specs(\@specs);
+  $self->_csv->_push_error($self->errors);
+  return ! $self->errors;
+}
+
+sub make_spec {
+  my ($self, $col, $path) = @_;
+
+  my $spec = { key => $col, steps => [] };
+  my $cur_class = $self->_csv->class;
+
+  for my $step_index ( split /\.(?!\d)/, $path ) {
+    my ($step, $index) = split /\./, $step_index;
+    if ($cur_class->can($step)) {
+      if (my $rel = $cur_class->meta->relationship($step)) { #a
+        if ($index && ! $rel->isa('Rose::DB::Object::Metadata::Relationship::OneToMany')) {
+          $self->_push_error([
+            $path,
+            undef,
+            "Profile path error. Indexed relationship is not OneToMany around here: '$step_index'",
+            undef,
+            0,
+          ]);
+          return;
+        } else {
+          my $next_class = $cur_class->meta->relationship($step)->class;
+          push @{ $spec->{steps} }, [ $step, $next_class, $index ];
+          $cur_class = $next_class;
+          eval "require $cur_class; 1" or die "could not load class '$cur_class'";
+        }
+      } else { # simple dispatch
+        push @{ $spec->{steps} }, [ $step ];
+        last;
+      }
+    } else {
+      $self->unknown_column($col, $path);
+    }
+  }
+
+  return $spec;
+}
+
+sub unknown_column {
+  my ($self, $col, $path) = @_;
+  return if $self->_csv->ignore_unknown_columns;
+
+  $self->_push_error([
+    $col,
+    undef,
+    "header field '$col' is not recognized",
+    undef,
+    0,
+  ]);
+}
+
+sub _csv {
+  $_[0]->{_csv};
+}
+
+sub errors {
+  @{ $_[0]->_errors }
+}
+
+sub _push_error {
+  my ($self, @errors) = @_;
+  my @new_errors = ($self->errors, map { SL::Helper::Csv::Error->new(@$_) } @errors);
+  $self->_errors(\@new_errors);
+}
+
+1;
diff --git a/SL/Helper/Csv/Error.pm b/SL/Helper/Csv/Error.pm
new file mode 100644 (file)
index 0000000..d3c9144
--- /dev/null
@@ -0,0 +1,16 @@
+package SL::Helper::Csv::Error;
+
+use strict;
+
+sub new {
+  my $class = shift;
+  bless [ @_ ], $class;
+}
+
+sub raw_input { $_->[0] }
+sub code      { $_->[1] }
+sub diag      { $_->[2] }
+sub col       { $_->[3] }
+sub line      { $_->[4] }
+
+1;
diff --git a/SL/SessionFile.pm b/SL/SessionFile.pm
new file mode 100644 (file)
index 0000000..dbed415
--- /dev/null
@@ -0,0 +1,186 @@
+package SL::SessionFile;
+
+use strict;
+
+use parent qw(Rose::Object);
+
+use Carp;
+use File::Path qw(mkpath rmtree);
+use English qw(-no_match_vars);
+use IO::File;
+use POSIX qw(strftime);
+
+use Rose::Object::MakeMethods::Generic
+(
+ scalar => [ qw(fh file_name) ],
+);
+
+sub new {
+  my ($class, $file_name, %params) = @_;
+
+  my $self   = $class->SUPER::new;
+
+  my $path   = $self->prepare_path;
+  $file_name =~ s:.*/::g;
+  $file_name =  "${path}/${file_name}";
+
+  if ($params{mode}) {
+    my $mode = $params{mode};
+
+    if ($params{encoding}) {
+      $params{encoding} =~ s/[^a-z0-9\-]//gi;
+      $mode .= ':encoding(' . $params{encoding} . ')';
+    }
+
+    $self->fh(IO::File->new($file_name, $mode));
+  }
+
+  $self->file_name($file_name);
+
+  return $self;
+}
+
+sub exists {
+  my ($self) = @_;
+  return -f $self->file_name;
+}
+
+sub size {
+  my ($self) = @_;
+  return -s $self->file_name;
+}
+
+sub displayable_mtime {
+  my ($self) = @_;
+  return '' unless $self->exists;
+
+  my @mtime = localtime((stat $self->file_name)[9]);
+  return $::locale->format_date(\%::myconfig, $mtime[5] + 1900, $mtime[4] + 1, $mtime[3]) . ' ' . strftime('%H:%M:%S', @mtime);
+}
+
+sub get_path {
+  die "No session ID" unless $::auth->get_session_id;
+  return "users/session_files/" . $::auth->get_session_id;
+}
+
+sub prepare_path {
+  my $path = get_path();
+  return $path if -d $path;
+  mkpath $path;
+  die "Creating ${path} failed" unless -d $path;
+  return $path;
+}
+
+sub destroy_session {
+  my ($class, $session_id) = @_;
+
+  $session_id =~ s/[^a-z0-9]//gi;
+  rmtree "users/session_files/$session_id" if $session_id;
+}
+
+1;
+__END__
+
+=pod
+
+=encoding utf8
+
+=head1 NAME
+
+SL::SessionFile - Create files that are removed when the session is
+destroyed or expires
+
+=head1 SYNOPSIS
+
+  use SL::SessionFile;
+
+  # Create a session file named "customer.csv" (relative names only)
+  my $sfile = SL::SessionFile->new("customer.csv", "w");
+  $sfile->fh->print("col1;col2;col3\n" .
+                    "value1;value2;value3\n");
+  $sfile->fh->close;
+
+  # Does temporary file exist?
+  my $sfile = SL::SessionFile->new("customer.csv");
+  if ($sfile->exists) {
+    print "file exists; size " . $sfile->size . " bytes; mtime " . $sfile->displayable_mtime . "\n";
+  }
+
+A small class that wraps around files that only exist as long as the
+user's session exists. The session expiration mechanism will delete
+all session files when the session itself is removed due to expiry or
+the user logging out.
+
+Files are stored in session-specific folders in
+C<users/session_files/SESSIONID>.
+
+=head1 MEMBER FUNCTIONS
+
+=over 4
+
+=item C<new $file_name, [%params]>
+
+Create a new instance. C<$file_name> is a relative file name (path
+components are stripped) to the session-specific temporary directory.
+
+If C<$params{mode}> is given then try to open the file as an instance
+of C<IO::File>. C<${mode}> is passed through to C<IO::File::new>.
+
+If C<$params{encoding}> is given then the file is opened with the
+appropriate encoding layer.
+
+=item C<fh>
+
+Returns the instance of C<IO::File> associated with the file.
+
+=item C<file_name>
+
+Returns the full relative file name associated with this instance. If
+it has been created for "customer.csv" then the value returned might
+be C<users/session_files/e8789b98721347/customer.csv>.
+
+=item C<exists>
+
+Returns trueish if the file exists.
+
+=item C<size>
+
+Returns the file's size in bytes.
+
+=item C<displayable_mtime>
+
+Returns the modification time suitable for display (e.g. date
+formatted according to the user's date format), e.g.
+C<22.01.2011 14:12:22>.
+
+=back
+
+=head1 OBJECT FUNCTIONS
+
+=over 4
+
+=item C<get_path>
+
+Returns the name of the session-specific directory used for file
+storage relative to the Lx-Office installation folder.
+
+=item C<prepare_path>
+
+Creates all directories in C<get_path> if they do not exist. Returns
+the same as C<get_path>.
+
+=item C<destroy_session $id>
+
+Removes all files and the directory belonging to the session C<$id>.
+
+=back
+
+=head1 BUGS
+
+Nothing here yet.
+
+=head1 AUTHOR
+
+Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>
+
+=cut
index 45a30f2..0fb3c71 100644 (file)
@@ -13,6 +13,8 @@ $self->{texts} = {
   ' Date missing!'              => ' Datum fehlt!',
   ' Part Number missing!'       => ' Artikelnummer fehlt!',
   ' missing!'                   => ' fehlt!',
+  '#1 (custom variable)'        => '#1 (benutzerdefinierte Variable)',
+  '#1 of #2 importable objects were imported.' => '#1 von #2 importierbaren Objekten wurden importiert.',
   '#1 prices were updated.'     => '#1 Preise wurden aktualisiert.',
   '*/'                          => '*/',
   '---please select---'         => '---bitte auswählen---',
@@ -182,6 +184,8 @@ $self->{texts} = {
   'Ap aging on %s'              => 'Offene Verbindlichkeiten zum %s',
   'Application Error. No Format given' => 'Fehler in der Anwendung. Das Ausgabeformat fehlt.',
   'Application Error. Wrong Format' => 'Fehler in der Anwendung. Falsches Format: ',
+  'Apply to all parts'          => 'Bei allen Artikeln setzen',
+  'Apply to parts without buchungsgruppe' => 'Bei allen Artikeln ohne gültige Buchungsgruppe setzen',
   'Applying #1:'                => 'Führe #1 aus:',
   'Approximately #1 prices will be updated.' => 'Ungefähr #1 Preise werden aktualisiert.',
   'Apr'                         => 'Apr',
@@ -197,6 +201,7 @@ $self->{texts} = {
   'Are you sure you want to update the prices' => 'Sind Sie sicher, dass Sie die Preise aktualisieren wollen?',
   'Article Code'                => 'Artikelkürzel',
   'Article Code missing!'       => 'Artikelkürzel fehlt',
+  'Article type (see below)'    => 'Artikeltyp (siehe unten)',
   'As a result, the saved onhand values of the present goods can be stored into a warehouse designated by you, or will be reset for a proper warehouse tracking' => 'Als Konsequenz k&ouml;nnen die gespeicherten Mengen entweder in ein Lager &uuml;berf&uuml;hrt werden, oder f&uuml;r eine frische Lagerverwaltung resettet werden.',
   'Assemblies'                  => 'Erzeugnisse',
   'Assembly Description'        => 'Erzeugnis-Beschreibung',
@@ -210,6 +215,7 @@ $self->{texts} = {
   'Assume Tax Consultant Data in Tax Computation?' => 'Beraterdaten in UStVA Ã¼bernehmen?',
   'At least'                    => 'Mindestens',
   'At least one Perl module that Lx-Office ERP requires for running is not installed on your system.' => 'Mindestes ein Perl-Modul, das Lx-Office ERP zur Ausf&uuml;hrung ben&ouml;tigt, ist auf Ihrem System nicht installiert.',
+  'At least one of the columns #1, customer, customernumber, vendor, vendornumber (depending on the target table) is required for matching the entry to an existing customer or vendor.' => 'Mindestens eine der Spalten #1, customer, customernumber, vendor, vendornumber (von Zieltabelle abhängig) wird benötigt, um einen Eintrag einem bestehenden Kunden bzw. Lieferanten zuzuordnen.',
   'At most'                     => 'H&ouml;chstens',
   'At the moment the transaction looks like this:' => 'Aktuell sieht die Buchung wie folgt aus:',
   'Attach PDF:'                 => 'PDF anhängen',
@@ -277,6 +283,7 @@ $self->{texts} = {
   'Birthday'                    => 'Geburtstag',
   'Bis'                         => 'bis',
   'Bis Konto: '                 => 'bis Konto: ',
+  'Block'                       => 'Block',
   'Body'                        => 'Text',
   'Body:'                       => 'Text:',
   'Books are open'              => 'Die Bücher sind geöffnet.',
@@ -287,6 +294,8 @@ $self->{texts} = {
   'Bought'                      => 'Gekauft',
   'Buchungsdatum'               => 'Buchungsdatum',
   'Buchungsgruppe'              => 'Buchungsgruppe',
+  'Buchungsgruppe (database ID)' => 'Buchungsgruppe (Datenbank-ID)',
+  'Buchungsgruppe (name)'       => 'Buchungsgruppe (Name)',
   'Buchungsgruppen'             => 'Buchungsgruppen',
   'Buchungskonto'               => 'Buchungskonto',
   'Buchungsnummer'              => 'Buchungsnummer',
@@ -295,6 +304,8 @@ $self->{texts} = {
   'Business deleted!'           => 'Firma gelöscht.',
   'Business evaluation'         => 'Betriebswirtschaftliche Auswertung',
   'Business saved!'             => 'Firma gespeichert.',
+  'Business type (database ID)' => 'Kunden-/Lieferantentyp (Datenbank-ID)',
+  'Business type (name)'        => 'Kunden-/Lieferantentyp (Name)',
   'CANCELED'                    => 'Storniert',
   'CB Transaction'              => 'SB-Buchung',
   'CR'                          => 'H',
@@ -313,6 +324,10 @@ $self->{texts} = {
   'CRM termin'                  => 'Termine',
   'CRM user'                    => 'Admin Benutzer',
   'CSV export -- options'       => 'CSV-Export -- Optionen',
+  'CSV import: contacts'        => 'CSV-Import: Ansprechpersonen',
+  'CSV import: customers and vendors' => 'CSV-Import: Kunden und Lieferanten',
+  'CSV import: parts and services' => 'CSV-Import: Waren und Dienstleistungen',
+  'CSV import: shipping addresses' => 'CSV-Import: Lieferadressen',
   'Calculate'                   => 'Berechnen',
   'Can not create that quantity with current stock' => 'Diese Anzahl kann mit dem gegenwärtigen Lagerbestand nicht hergestellt werden.',
   'Cancel'                      => 'Abbrechen',
@@ -354,6 +369,7 @@ $self->{texts} = {
   'Change representative to'    => 'Vertreter Ã¤ndern in',
   'Charge Number'               => 'Chargennummer',
   'Charge number'               => 'Chargennummer',
+  'Charset'                     => 'Zeichensatz',
   'Chart'                       => 'Buchungskonto',
   'Chart Type'                  => 'Kontentyp',
   'Chart balance'               => 'Kontensaldo',
@@ -362,6 +378,7 @@ $self->{texts} = {
   'Chartaccounts connected to this Tax:' => 'Konten, die mit dieser Steuer verknüpft sind:',
   'Check'                       => 'Scheck',
   'Check Details'               => 'Bitte Angaben Ã¼berprüfen',
+  'Check for duplicates'        => 'Dublettencheck',
   'Checks'                      => 'Schecks',
   'Choose Customer'             => 'Endkunde wählen:',
   'Choose Outputformat'         => 'Ausgabeformat auswählen...',
@@ -377,6 +394,8 @@ $self->{texts} = {
   'Close Window'                => 'Fenster Schlie&szlig;en',
   'Closed'                      => 'Geschlossen',
   'Collective Orders only work for orders from one customer!' => 'Sammelaufträge funktionieren nur für Aufträge von einem Kunden!',
+  'Column name'                 => 'Spaltenname',
+  'Comma'                       => 'Komma',
   'Comment'                     => 'Kommentar',
   'Company'                     => 'Firma',
   'Company Name'                => 'Firmenname',
@@ -454,11 +473,13 @@ $self->{texts} = {
   'Current / Next Level'        => 'Aktuelles / Nächstes Mahnlevel',
   'Current Earnings'            => 'Gewinn',
   'Current assets account'      => 'Konto für Umlaufvermögen',
+  'Current profile'             => 'Aktuelles Profil',
   'Current unit'                => 'Aktuelle Einheit',
   'Current value:'              => 'Aktueller Wert:',
   'Custom Variables'            => 'Benutzerdefinierte Variablen',
   'Custom variables for module' => 'Benutzerdefinierte Variablen für Modul',
   'Customer'                    => 'Kunde',
+  'Customer (name)'             => 'Kunde (Name)',
   'Customer Name'               => 'Kundenname',
   'Customer Number'             => 'Kundennummer',
   'Customer Order Number'       => 'Bestellnummer des Kunden',
@@ -469,6 +490,8 @@ $self->{texts} = {
   'Customer not on file!'       => 'Kunde ist nicht in der Datenbank!',
   'Customer saved!'             => 'Kunde gespeichert!',
   'Customer type'               => 'Kundentyp',
+  'Customer/Vendor'             => 'Kunde/Lieferant',
+  'Customer/Vendor (database ID)' => 'Kunde/Lieferant (Datenbank-ID)',
   'Customername'                => 'Kundenname',
   'Customernumberinit'          => 'Kunden-/Lieferantennummernkreis',
   'Customers'                   => 'Kunden',
@@ -519,6 +542,7 @@ $self->{texts} = {
   'Decrease'                    => 'Verringern',
   'Default (no language selected)' => 'Standard (keine Sprache ausgewählt)',
   'Default Accounts'            => 'Standardkonten',
+  'Default buchungsgruppe'      => 'Standardbuchungsgruppe',
   'Default output medium'       => 'Standardausgabekanal',
   'Default printer'             => 'Standarddrucker',
   'Default template format'     => 'Standardvorlagenformat',
@@ -532,6 +556,7 @@ $self->{texts} = {
   'Delete delivery order'       => 'Lieferschein l&ouml;schen',
   'Delete drafts'               => 'Entwürfe löschen',
   'Delete group'                => 'Gruppe l&ouml;schen',
+  'Delete profile'              => 'Profil löschen',
   'Delete transaction'          => 'Buchung löschen',
   'Delivered'                   => 'Geliefert',
   'Delivery Date'               => 'Lieferdatum',
@@ -542,7 +567,9 @@ $self->{texts} = {
   'Delivery Order deleted!'     => 'Lieferschein gel&ouml;scht!',
   'Delivery Orders'             => 'Lieferscheine',
   'Department'                  => 'Abteilung',
-  'Department Id'               => 'Abteilungsnummer',
+  'Department 1'                => 'Abteilung (1)',
+  'Department 2'                => 'Abteilung (2)',
+  'Department Id'               => 'Reservierung',
   'Department deleted!'         => 'Abteilung gelöscht.',
   'Department saved!'           => 'Abteilung gespeichert.',
   'Departments'                 => 'Abteilungen',
@@ -550,6 +577,7 @@ $self->{texts} = {
   'Deposit'                     => 'Gutschrift',
   'Description'                 => 'Beschreibung',
   'Description (Click on Description for details)' => 'Beschreibung (Klick Ã¶ffnet einzelne Kontendetails)',
+  'Description (translation for #1)' => 'Beschreibung (Ãœbersetzung für #1)',
   'Description missing!'        => 'Beschreibung fehlt.',
   'Description must not be empty!' => 'Beschreibung darf nicht leer sein',
   'Destination BIC'             => 'Ziel-BIC',
@@ -561,10 +589,14 @@ $self->{texts} = {
   'Difference'                  => 'Differenz',
   'Dimension unit'              => 'Ma&szlig;einheit',
   'Directory'                   => 'Verzeichnis',
+  'Discard duplicate entries in CSV file' => 'Doppelte Einträge in CSV-Datei verwerfen',
+  'Discard entries with duplicates in database or CSV file' => 'Einträge aus CSV-Datei verwerfen, die es bereits in der Datenbank oder der CSV-Datei gibt',
   'Discount'                    => 'Rabatt',
   'Display'                     => 'Anzeigen',
   'Display file'                => 'Datei anzeigen',
   'Display options'             => 'Anzeigeoptionen',
+  'Do not check for duplicates' => 'Nicht nach Dubletten suchen',
+  'Do not set default buchungsgruppe' => 'Nie Standardbuchungsgruppe setzen',
   'Do you really want to close the following SEPA exports? No payment will be recorded for bank collections that haven\'t been marked as executed yet.' => 'Wollen Sie wirklich die folgenden SEPA-Exporte abschließen? Für Ãœberweisungen, die noch nicht gebucht wurden, werden dann keine Zahlungen verbucht.',
   'Do you really want to close the following SEPA exports? No payment will be recorded for bank transfers that haven\'t been marked as executed yet.' => 'Wollen Sie wirklich die folgenden SEPA-Exporte abschließen? Für Ãœberweisungen, die noch nicht gebucht wurden, werden dann keine Zahlungen verbucht.',
   'Do you really want to delete AP transaction #1?' => 'Wollen Sie wirklich die Kreditorenbuchung #1 löschen?',
@@ -581,6 +613,7 @@ $self->{texts} = {
   'Documents in the WebDAV repository' => 'Dokumente im WebDAV-Repository',
   'Done'                        => 'Fertig',
   'Download SEPA XML export file' => 'SEPA-XML-Exportdatei herunterladen',
+  'Download sample file'        => 'Beispieldatei herunterladen',
   'Download the backup'         => 'Die Sicherungsdatei herunterladen',
   'Draft saved.'                => 'Entwurf gespeichert.',
   'Drawing'                     => 'Zeichnung',
@@ -605,6 +638,8 @@ $self->{texts} = {
   'Dunning number'              => 'Mahnungsnummer',
   'Dunning overview'            => 'Mahnungsübersicht',
   'Dunnings'                    => 'Mahnungen',
+  'Duplicate in CSV file'       => 'Duplikat in CSV-Datei',
+  'Duplicate in database'       => 'Duplikat in Datenbank',
   'During this user migration Lx-Office can create such a group for you and grant all users access to all of Lx-Office\'s functions.' => 'Im Rahmen dieser Benutzerdatenmigration kann Lx-Office eine solche Gruppe f&uuml;r Sie anlegen und allen Benutzern Zugriff auf alle Lx-Office-Funktionen gew&auml;hren.',
   'E-mail'                      => 'eMail',
   'E-mail Statement to'         => 'Fälligkeitsabrechnung als eMail an',
@@ -706,7 +741,21 @@ $self->{texts} = {
   '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&uuml;ssen einer Position entweder gar keinen Lagerausgang oder die vollst&auml;ndige im Lieferschein vermerkte Menge von #2 #3 zuweisen.',
   'Error in row #1: The quantity you entered is bigger than the stocked quantity.' => 'Fehler in Zeile #1: Die angegebene Menge ist gr&ouml;&szlig;er als die vorhandene Menge.',
   'Error message from the database driver:' => 'Fehlermeldung des Datenbanktreibers:',
+  'Error when saving: #1'       => 'Fehler beim Speichern: #2',
   'Error!'                      => 'Fehler!',
+  'Error: Buchungsgruppe missing or invalid' => 'Fehler: Buchungsgruppe fehlt oder ungültig',
+  'Error: Customer/vendor not found' => 'Fehler: Kunde/Lieferant nicht gefunden',
+  'Error: Gender (cp_gender) missing or invalid' => 'Fehler: Geschlecht (cp_gender) fehlt oder ungültig',
+  'Error: Invalid business'     => 'Fehler: Kunden-/Lieferantentyp ungültig',
+  'Error: Invalid language'     => 'Fehler: Sprache 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',
+  'Error: Invalid vendor in column make_#1' => 'Fehler: Lieferant ungültig in Spalte make_#1',
+  'Error: Name missing'         => 'Fehler: Name fehlt',
+  'Error: Unit missing or invalid' => 'Fehler: Einheit fehlt oder ungültig',
+  'Errors'                      => 'Fehler',
   'Ertrag'                      => 'Ertrag',
   'Ertrag prozentual'           => 'Ertrag prozentual',
   'Escape character'            => 'Escape-Zeichen',
@@ -724,7 +773,9 @@ $self->{texts} = {
   'Execution date to'           => 'Ausführungsdatum bis',
   'Existing Buchungsgruppen'    => 'Existierende Buchungsgruppen',
   'Existing Datasets'           => 'Existierende Datenbanken',
+  'Existing file on server'     => 'Auf dem Server existierende Datei',
   'Existing pending follow-ups for this item' => 'Noch nicht erledigte Wiedervorlagen f&uuml;r dieses Dokument',
+  'Existing profiles'           => 'Existierende Profile',
   'Expected Tax'                => 'Erwartete Steuern',
   'Expense'                     => 'Aufwandskonto',
   'Expense Account'             => 'Aufwandskonto',
@@ -782,6 +833,8 @@ $self->{texts} = {
   'Foreign Revenues'            => 'Erl&ouml;se Ausland',
   'Form details (second row)'   => 'Formulardetails (zweite Positionszeile)',
   'Formula'                     => 'Formel',
+  'Found #1 errors.'            => '#1 Fehler gefunden.',
+  'Found #1 objects of which #2 can be imported.' => 'Es wurden #1 Objekte gefunden, von denen #2 importiert werden können.',
   'Free report period'          => 'Freier Zeitraum',
   'Free-form text'              => 'Textzeile',
   'Fristsetzung'                => 'Fristsetzung',
@@ -820,8 +873,10 @@ $self->{texts} = {
   'Headings'                    => 'Ãœberschriften',
   'Help'                        => 'Hilfe',
   'Help Template Variables'     => 'Hilfe zu Dokumenten-Variablen',
+  'Help on column names'        => 'Hilfe zu Spaltennamen',
   'Here\'s an example command line:' => 'Hier ist eine Kommandozeile, die als Beispiel dient:',
   'Hide by default'             => 'Standardm&auml;&szlig;ig verstecken',
+  'Hide help text'              => 'Hilfetext verbergen',
   'History'                     => 'Historie',
   'History Search'              => 'Historien Suche',
   'History Search Engine'       => 'Historien Suchmaschine',
@@ -835,6 +890,7 @@ $self->{texts} = {
   'II'                          => 'II',
   'III'                         => 'III',
   'IV'                          => 'IV',
+  'If the article type is set to \'mixed\' then a column called \'type\' must be present.' => 'Falls der Artikeltyp auf \'gemischt\' gestellt wird, muss eine Spalte namens \'type\' vorhanden sein.',
   'If the automatic creation of invoices for fees and interest is switched on for a dunning level then the following accounts will be used for the invoice.' => 'Wenn das automatische Erstellen einer Rechnung &uuml;ber Mahngeb&uuml;hren und Zinsen f&uuml;r ein Mahnlevel aktiviert ist, so werden die folgenden Konten f&uuml;r die Rechnung benutzt.',
   'If the database user listed above does not have the right to create a database then enter the name and password of the superuser below:' => 'Falls der oben genannte Datenbankbenutzer nicht die Berechtigung zum Anlegen neuer Datenbanken hat, so k&ouml;nnen Sie hier den Namen und das Passwort des Datenbankadministratoraccounts angeben:',
   'If you chose to let Lx-Office do the migration then Lx-Office will also remove the old member file after creating a backup copy of it in the directory &quot;#1&quot;.' => 'Falls Sie sich entscheiden, Lx-Office die Migration durchführen zu lassen, so wird Lx-Office ein Backup der alten Dateien im Verzeichnis "#1" erstellen und die Dateien anschließend löschen.',
@@ -846,7 +902,13 @@ $self->{texts} = {
   'If you want to set up the authentication database yourself then log in to the administration panel. Lx-Office will then create the database and tables for you.' => 'Wenn Sie die Authentifizierungsdatenbank selber einrichten wollen, so melden Sie sich an der Administrationsoberfl&auml;che an. Lx-Office wird dann die Datenbank und die Tabellen f&uuml;r Sie anlegen.',
   'If you yourself want to upgrade the installation then please read the file &quot;doc/UPGRADE&quot; and follow the steps outlined in this file.' => 'Wenn Sie selber die Aktualisierung bzw. Einrichtung &uuml;bernehmen wollen, so lesen Sie bitte die Datei &quot;doc/UPGRADE&quot; und folgen Sie den dort beschriebenen Schritten.',
   'Image'                       => 'Grafik',
+  'Import'                      => 'Import',
   'Import CSV'                  => 'CSV-Import',
+  'Import file'                 => 'Import-Datei',
+  'Import preview'              => 'Import-Vorschau',
+  'Import profiles'             => 'Import-Profil',
+  'Import result'               => 'Import-Ergebnis',
+  'Import summary'              => 'Import-Zusammenfassung',
   'In Lx-Office 2.4.0 the administrator has to enter a list of units in the administrative section.' => 'In Lx-Office 2.4.0 muss der Administrator in den Systemeinstellungen eine Liste von verwendbaren Einheiten angeben.',
   'In order to do that hit the button "Delete transaction".' => 'Drücken Sie dafür auf den Button "Buchung löschen".',
   'In the latter case the tables needed by Lx-Office will be created in that database.' => 'In letzterem Fall werden die von Lx-Office benötigten Tabellen in dieser existierenden Datenbank angelegt.',
@@ -868,6 +930,7 @@ $self->{texts} = {
   'Increase'                    => 'Erhöhen',
   'Individual Items'            => 'Einzelteile',
   'Information'                 => 'Information',
+  'Insert with new part number' => 'Mit neuer Artikelnummer einfügen',
   'Interest'                    => 'Zinsen',
   'Interest Rate'               => 'Zinssatz',
   'Internal Notes'              => 'Interne Bemerkungen',
@@ -927,12 +990,13 @@ $self->{texts} = {
   'Konten'                      => 'Konten',
   'Kontonummernerweiterung (KNE)' => 'Kontonummernerweiterung (KNE)',
   'L'                           => 'L',
-  'LANGUAGES'                   => '',
   'LIABILITIES'                 => 'PASSIVA',
   'LP'                          => 'LP',
   'LaTeX Templates'             => 'LaTeX-Vorlagen',
   'Landscape'                   => 'Querformat',
   'Language'                    => 'Sprache',
+  'Language (database ID)'      => 'Sprache (Datenbank-ID)',
+  'Language (name)'             => 'Sprache (Name)',
   'Language Values'             => 'Sprachübersetzungen',
   'Language deleted!'           => 'Sprache gelöscht!',
   'Language missing!'           => 'Sprache fehlt!',
@@ -961,6 +1025,7 @@ $self->{texts} = {
   'Licenses'                    => 'Lizenzen',
   'Limit part selection'        => 'Artikelauswahl eingrenzen',
   'Line Total'                  => 'Zeilensumme',
+  'Line and column'             => 'Zeile und Spalte',
   'Line endings'                => 'Zeilenumbr&uuml;che',
   'List'                        => 'Anzeigen',
   'List Accounting Groups'      => 'Buchungsgruppen anzeigen',
@@ -984,6 +1049,7 @@ $self->{texts} = {
   'List of custom variables'    => 'Liste der benutzerdefinierten Variablen',
   'List open SEPA exports'      => 'Noch nicht ausgeführte SEPA-Exporte anzeigen',
   'Load draft'                  => 'Entwurf laden',
+  'Load profile'                => 'Profil laden',
   'Local Tax Office Preferences' => 'Angaben zum Finanzamt',
   'Lock System'                 => 'System sperren',
   'Lockfile created!'           => 'System gesperrt!',
@@ -1008,6 +1074,8 @@ $self->{texts} = {
   'Main Preferences'            => 'Grundeinstellungen',
   'Main sorting'                => 'Hauptsortierung',
   'Make'                        => 'Lieferant',
+  'Make (with X being a number)' => 'Lieferant (X ist eine fortlaufende Zahl)',
+  'Make default profile'        => 'Zu Standardprofil machen',
   'Manage Custom Variables'     => 'Benutzerdefinierte Variablen',
   'Manage license keys'         => 'Lizenzschl&uuml;ssel verwalten',
   'Mandantennummer'             => 'Mandantennummer',
@@ -1019,6 +1087,7 @@ $self->{texts} = {
   'Margins'                     => 'Seitenr&auml;nder',
   'Mark as closed'              => 'Abschließen',
   'Mark as paid?'               => 'Als bezahlt markieren?',
+  'Mark as shop article if column missing' => 'Als Shopartikel setzen, falls Spalte nicht vorhanden',
   'Mark closed'                 => 'Als geschlossen markieren',
   'Marked as paid'              => 'Als bezahlt markiert',
   'Marked entries printed!'     => 'Markierte Einträge wurden gedruckt!',
@@ -1027,6 +1096,7 @@ $self->{texts} = {
   'May'                         => 'Mai',
   'May '                        => 'Mai',
   'May set the BCC field when sending emails' => 'Beim Verschicken von Emails das Feld \'BCC\' setzen',
+  'Meaning'                     => 'Bedeutung',
   'Medium Number'               => 'Datentr&auml;gernummer',
   'Memo'                        => 'Memo',
   'Menu'                        => 'Men&uuml;',
@@ -1044,9 +1114,11 @@ $self->{texts} = {
   'Missing parameter (at least one of #1) in call to sub #2.' => 'Fehlernder Parameter (mindestens einer aus \'#1\') in Funktionsaufruf \'#2\'.',
   'Missing taxkeys in invoices with taxes.' => 'Fehlende Steuerschl&uuml;ssel in Rechnungen mit Steuern',
   'Mitarbeiter'                 => 'Mitarbeiter',
+  'Mixed (requires column "type")' => 'Gemischt (erfordert Spalte "type")',
   'Mobile1'                     => 'Mobile 1',
   'Mobile2'                     => 'Mobile 2',
   'Model'                       => 'Lieferanten-Art-Nr.',
+  'Model (with X being a number)' => 'Lieferanten-Art-Nr. (X ist eine fortlaufende Zahl)',
   'Module'                      => 'Modul',
   'Module home page'            => 'Modul-Webseite',
   'Module name'                 => 'Modulname',
@@ -1096,6 +1168,7 @@ $self->{texts} = {
   'No datasets have been selected.' => 'Es wurden keine Datenbanken ausgew&auml;hlt.',
   'No dunnings have been selected for printing.' => 'Es wurden keine Mahnungen zum Drucken ausgew&auml;hlt.',
   'No entries were found which had no unit assigned to them.' => 'Es wurden keine Eintr&auml;ge gefunden, denen keine Einheit zugeordnet war.',
+  'No file has been uploaded yet.' => 'Es wurde noch keine Datei hochgeladen.',
   'No group has been selected, or the group does not exist anymore.' => 'Es wurde keine Gruppe ausgew&auml;hlt, oder die Gruppe wurde in der Zwischenzeit gel&ouml;scht.',
   'No groups have been added yet.' => 'Es wurden noch keine Gruppen angelegt.',
   'No licenses were found that match the search criteria.' => 'Es wurden keine Lizenzen gefunden, auf die die Suchkriterien zutreffen.',
@@ -1122,6 +1195,7 @@ $self->{texts} = {
   'Note: For Firefox 4 and later the menu XUL menu requires the addon <a href="#1">Remote XUL Manager</a> and the Lx-Office server to be white listed.' => 'Bitte beachten: Ab Firefox 4 benötigt das XUL Menü das Addon <a href="#1">Remote XUL Manager</a>, in dem der Lx-Office Server eingetragen sein muss.',
   'Note: Taxkeys must have a "valid from" date, and will not behave correctly without.' => 'Hinweis: Steuerschlüssel sind fehlerhaft ohne "Gültig ab" Datum',
   'Notes'                       => 'Bemerkungen',
+  'Notes (translation for #1)'  => 'Bemerkungen (Ãœbersetzung für #1)',
   'Notes (will appear on hard copy)' => 'Bemerkungen',
   'Nothing has been selected for removal.' => 'Es wurde nichts f&uuml;r eine Entnahme ausgew&auml;hlt.',
   'Nothing has been selected for transfer.' => 'Es wurde nichts zum Umlagern ausgew&auml;hlt.',
@@ -1141,6 +1215,7 @@ $self->{texts} = {
   'Number variables: \'PRECISION=n\' forces numbers to be shown with exactly n decimal places.' => 'Zahlenvariablen: Mit \'PRECISION=n\' erzwingt man, dass Zahlen mit n Nachkommastellen formatiert werden.',
   'OB Transaction'              => 'EB-Buchung',
   'OBE-Export erfolgreich!'     => 'OBE-Export erfolgreich!',
+  'Objects have been imported.' => 'Objekte wurden importiert.',
   'Obsolete'                    => 'Ungültig',
   'Oct'                         => 'Okt',
   'October'                     => 'Oktober',
@@ -1152,6 +1227,7 @@ $self->{texts} = {
   'On Order'                    => 'Ist bestellt',
   'One or more Perl modules missing' => 'Ein oder mehr Perl-Module fehlen',
   'Only due follow-ups'         => 'Nur f&auml;llige Wiedervorlagen',
+  'Oops. No valid action found to dispatch. Please report this case to the Lx-Office team.' => '',
   'Open'                        => 'Offen',
   'Open Amount'                 => 'Offener Betrag',
   'Open a further Lx-Office Window or Tab' => 'Neues Fenster bzw. Tab &ouml;ffnen',
@@ -1204,7 +1280,10 @@ $self->{texts} = {
   'Parts'                       => 'Waren',
   'Parts Inventory'             => 'Warenliste',
   'Parts must have an entry type.' => 'Waren m&uuml;ssen eine Buchungsgruppe haben.',
+  'Parts with existing part numbers' => 'Artikel mit existierender Artikelnummer',
   'Parts, services and assemblies' => 'Waren, Dienstleistungen und Erzeugnisse',
+  'Partsgroup (database ID)'    => 'Warengruppe (Datenbank-ID)',
+  'Partsgroup (name)'           => 'Warengruppe (Name)',
   'Password'                    => 'Passwort',
   'Payables'                    => 'Verbindlichkeiten',
   'Payment'                     => 'Zahlungsausgang',
@@ -1217,7 +1296,9 @@ $self->{texts} = {
   'Payment list as PDF'         => 'Zahlungsliste als PDF',
   'Payment posted!'             => 'Zahlung gebucht!',
   'Payment terms'               => 'Zahlungsbedingungen',
-  'Payments'                    => 'Zahlungsausgänge',
+  'Payment terms (database ID)' => 'Zahlungsbedingungen (Datenbank-ID)',
+  'Payment terms (name)'        => 'Zahlungsbedingungen (Name)',
+  'Payments'                    => 'Zahlungsausgünge',
   'Per. Inv.'                   => 'Wied. Rech.',
   'Period'                      => 'Zeitraum',
   'Period:'                     => 'Zeitraum:',
@@ -1236,6 +1317,7 @@ $self->{texts} = {
   'Please ask your administrator to create warehouses and bins.' => 'Bitten Sie Ihren Administrator, dass er Lager und Lagerpl&auml;tze anlegt.',
   'Please enter a license key.' => 'Bitte geben Sie einen Lizenzschlüssel an.',
   'Please enter a number of licenses.' => 'Bitte geben Sie die Anzahl Lizenzschlüssel an.',
+  'Please enter a profile name.' => 'Bitte geben Sie einen Profilnamen an.',
   'Please enter the login for the new user.' => 'Bitte geben Sie das Login für den neuen Benutzer ein.',
   'Please enter the name of the database that will be used as the template for the new database:' => 'Bitte geben Sie den Namen der Datenbank an, die als Vorlage f&uuml;r die neue Datenbank benutzt wird:',
   'Please enter the name of the dataset you want to restore the backup in.' => 'Bitte geben Sie den Namen der Datenbank ein, in der Sie die Sicherung wiederherstellen wollen.',
@@ -1281,6 +1363,8 @@ $self->{texts} = {
   'Price'                       => 'Preis',
   'Price Factor'                => 'Preisfaktor',
   'Price Factors'               => 'Preisfaktoren',
+  'Price factor (database ID)'  => 'Preisfaktor (Datenbank-ID)',
+  'Price factor (name)'         => 'Preisfaktor (Name)',
   'Price factor deleted!'       => 'Preisfaktor gel&ouml;scht.',
   'Price factor saved!'         => 'Preisfaktor gespeichert.',
   'Pricegroup'                  => 'Preisgruppe',
@@ -1348,8 +1432,10 @@ $self->{texts} = {
   'Quotation Number missing!'   => 'Angebotsnummer fehlt!',
   'Quotation deleted!'          => 'Angebot wurde gelöscht.',
   'Quotations'                  => 'Angebote',
+  'Quote character'             => 'Anführungszeichen-Symbol',
   'Quote chararacter'           => 'Anf&uuml;hrungszeichen',
   'Quoted'                      => 'Angeboten',
+  'Quotes'                      => 'Doppelte Anführungszeichen',
   'RFQ'                         => 'Anfrage',
   'RFQ Date'                    => 'Anfragedatum',
   'RFQ Number'                  => 'Anfragenummer',
@@ -1452,6 +1538,8 @@ $self->{texts} = {
   'Save and close'              => 'Speichern und schlie&szlig;en',
   'Save as new'                 => 'als neu speichern',
   'Save draft'                  => 'Entwurf speichern',
+  'Save profile'                => 'Profil speichern',
+  'Save settings as'            => 'Einstellungen speichern unter',
   'Saving the file \'%s\' failed. OS error message: %s' => 'Das Speichern der Datei \'%s\' schlug fehl. Fehlermeldung des Betriebssystems: %s',
   'Screen'                      => 'Bildschirm',
   'Search AP Aging'             => 'Offene Verbindlichkeiten',
@@ -1479,8 +1567,14 @@ $self->{texts} = {
   'Selection'                   => 'Auswahlbox',
   'Selection fields: The option field must contain the available options for the selection. Options are separated by \'##\', for example \'Early##Normal##Late\'.' => 'Auswahlboxen: Das Optionenfeld muss die f&uuml;r die Auswahl verf&uuml;gbaren Eintr&auml;ge enthalten. Die Eintr&auml;ge werden mit \'##\' voneinander getrennt. Beispiel: \'Fr&uuml;h##Normal##Sp&auml;t\'.',
   'Sell Price'                  => 'Verkaufspreis',
+  'Sellprice'                   => 'Verkaufspreis',
+  'Sellprice adjustment'        => 'Verkaufspreis: Preisanpassung',
+  'Sellprice for price group \'#1\'' => 'Verkaufspreis für Preisgruppe \'#1\'',
+  'Sellprice significant places' => 'Verkaufspreis: Nachkommastellen',
+  'Semicolon'                   => 'Semikolon',
   'Send the backup via Email'   => 'Die Sicherungsdatei per Email verschicken',
   'Sep'                         => 'Sep',
+  'Separator'                   => 'Trennzeichen',
   'Separator chararacter'       => 'Feldtrennzeichen',
   'September'                   => 'September',
   'Serial No.'                  => 'Seriennummer',
@@ -1492,6 +1586,7 @@ $self->{texts} = {
   'Services'                    => 'Dienstleistungen',
   'Set Language Values'         => 'Spracheinstellungen',
   'Set eMail text'              => 'eMail Text eingeben',
+  'Settings'                    => 'Einstellungen',
   'Setup Menu'                  => 'Menü-Variante',
   'Setup Templates'             => 'Vorlagen auswählen',
   'Ship to'                     => 'Lieferadresse',
@@ -1508,11 +1603,13 @@ $self->{texts} = {
   'Show custom variable search inputs' => 'Suchoptionen für Benutzerdefinierte Variablen verstecken',
   'Show details'                => 'Detailsanzeige',
   'Show follow ups...'          => 'Zeige Wiedervorlagen...',
+  'Show help text'              => 'Hilfetext anzeigen',
   'Show old dunnings'           => 'Alte Mahnungen anzeigen',
   'Show overdue sales quotations and requests for quotations...' => 'Ãœberfällige Angebote und Preisanfragen anzeigen...',
   'Show your TODO list after loggin in' => 'Aufgabenliste nach dem Anmelden anzeigen',
   'Signature'                   => 'Unterschrift',
   'Since bin is not enforced in the parts data, please specify a bin where goods without a specified bin will be put.' => 'Da Lagerpl&auml;tze kein Pflichtfeld sind, geben Sie bitte einen Lagerplatz an, in dem Waren ohne spezifizierten Lagerplatz eingelagert werden sollen.',
+  'Single quotes'               => 'Einfache Anführungszeichen',
   'Skip'                        => 'Ãœberspringen',
   'Skonto'                      => 'Skonto',
   'Skonto Terms'                => 'Zahlungsziel Skonto',
@@ -1523,6 +1620,7 @@ $self->{texts} = {
   'Source IBAN'                 => 'Quell-IBAN',
   'Source bank account'         => 'Quellkonto',
   'Source bin'                  => 'Quelllagerplatz',
+  'Space'                       => 'Leerzeichen',
   'Spoolfile'                   => 'Druckdatei',
   'Start Dunning Process'       => 'Mahnprozess starten',
   'Start analysis'              => 'Analyse beginnen',
@@ -1567,7 +1665,9 @@ $self->{texts} = {
   'TODO list options'           => 'Aufgabenlistenoptionen',
   'TOP100'                      => 'Top 100',
   'TOTAL'                       => 'TOTAL',
+  'Tab'                         => 'Tabulator',
   'Target bank account'         => 'Zielkonto',
+  'Target table'                => 'Zieltabelle',
   'Tax'                         => 'Steuer',
   'Tax Consultant'              => 'Steuerberater/-in',
   'Tax Included'                => 'Steuer im Preis inbegriffen',
@@ -1607,6 +1707,7 @@ $self->{texts} = {
   'Template database'           => 'Datenbankvorlage',
   'Templates'                   => 'Vorlagen',
   'Terms missing in row '       => '+Tage fehlen in Zeile ',
+  'Test and preview'            => 'Test und Vorschau',
   'Test connection'             => 'Verbindung testen',
   'Text field'                  => 'Textfeld',
   'Text field variables: \'WIDTH=w HEIGHT=h\' sets the width and height of the text field. They default to 30 and 5 respectively.' => 'Textfelder: \'WIDTH=w HEIGHT=h\' setzen die Breite und die H&ouml;he des Textfeldes. Wenn nicht anders angegeben, so werden sie 30 Zeichen breit und f&uuml;nf Zeichen hoch dargestellt.',
@@ -1711,6 +1812,8 @@ $self->{texts} = {
   'The pg_dump process could not be started.' => 'Der pg_dump-Prozess konnte nicht gestartet werden.',
   'The pg_restore process could not be started.' => 'Der pg_restore-Prozess konnte nicht gestartet werden.',
   'The preferred one is to install packages provided by your operating system distribution (e.g. Debian or RPM packages).' => 'Die bevorzugte Art, ein Perl-Modul zu installieren, ist durch Installation eines von Ihrem Betriebssystem zur Verf&uuml;gung gestellten Paketes (z.B. Debian-Pakete oder RPM).',
+  'The profile \'#1\' has been deleted.' => 'Das Profil \'#1\' wurde gelöscht.',
+  'The profile has been saved under the name \'#1\'.' => 'Das Profil wurde unter dem Namen \'#1\' gespeichert.',
   'The program\'s exit code was #1 (&quot;0&quot; usually means that everything went OK).' => 'Der Exitcode des Programms war #1 (&quot;0&quot; bedeutet normalerweise, dass die Wiederherstellung erfolgreich war).',
   'The project has been added.' => 'Das Projekt wurde erfasst.',
   'The project has been saved.' => 'Das Projekt wurde gespeichert.',
@@ -1827,6 +1930,7 @@ $self->{texts} = {
   'Trial balance between %s and %s' => 'Summen- und Saldenlisten vom %s bis zum %s',
   'Trying to call a sub without a name' => 'Es wurde versucht, eine Unterfunktion ohne Namen aufzurufen.',
   'Type'                        => 'Typ',
+  'Type can be either \'part\' or \'service\'.' => 'Der Typ kann entweder \'part\' (für Waren) oder \'service\' (für Dienstleistungen) enthalten.',
   'Type of Business'            => 'Kunden-/Lieferantentyp',
   'Type of Customer'            => 'Kundentyp',
   'Type of Vendor'              => 'Lieferantentyp',
@@ -1864,8 +1968,11 @@ $self->{texts} = {
   'Update SKR04: new tax account 3804 (19%)' => 'Update SKR04: neues Steuerkonto 3804 (19%) für innergemeinschaftlichen Erwerb',
   'Update complete'             => 'Update beendet.',
   'Update prices'               => 'Preise aktualisieren',
+  'Update prices of existing entries' => 'Preise von vorhandenen Artikeln aktualisieren',
   'Update?'                     => 'Aktualisieren?',
   'Updated'                     => 'Erneuert am',
+  'Updating prices of existing entry in database' => 'Preis des Eintrags in der Datenbank wird aktualisiert',
+  'Uploaded on #1, size #2 kB'  => 'Am #1 hochgeladen, Größe #2 kB',
   'Use As Template'             => 'Als Vorlage verwenden',
   'Use Templates'               => 'Benutze Vorlagen',
   'User'                        => 'Benutzer',
@@ -1886,6 +1993,7 @@ $self->{texts} = {
   'Variable Description'        => 'Datenfeldbezeichnung',
   'Variable Name'               => 'Datenfeldname (intern)',
   'Vendor'                      => 'Lieferant',
+  'Vendor (name)'               => 'Lieferant (Name)',
   'Vendor Invoice'              => 'Einkaufsrechnung',
   'Vendor Invoices'             => 'Einkaufsrechnungen',
   'Vendor Name'                 => 'Lieferantenname',
@@ -1995,6 +2103,7 @@ $self->{texts} = {
   'Zipcode'                     => 'PLZ',
   'Zusatz'                      => 'Zusatz',
   '[email]'                     => '[email]',
+  'absolute'                    => 'absolut',
   'account_description'         => 'Beschreibung',
   'accrual'                     => 'Bilanzierung (Soll-Versteuerung)',
   'action= not defined!'        => 'action= nicht definiert!',
@@ -2084,6 +2193,7 @@ $self->{texts} = {
   'order'                       => 'Reihenfolge',
   'our vendor number at customer' => 'Unsere Lieferanten-Nr. beim Kunden',
   'part_list'                   => 'warenliste',
+  'percental'                   => 'prozentual',
   'pick_list'                   => 'Sammelliste',
   'plural first char'           => 'P',
   'pos_bilanz'                  => 'Bilanz',
index 0dd0a5e..10020f8 100644 (file)
--- a/menu.ini
+++ b/menu.ini
@@ -755,23 +755,49 @@ action=list_warehouses
 #action=import
 #type=Datapreis
 
+# [System--Import CSV]
+# module=menu.pl
+# action=acc_menu
+# target=acc_menu
+# submenu=1
+
+# [System--Import CSV--Customer]
+# module=lxo-import/addressB.php
+
+# [System--Import CSV--Contacts]
+# module=lxo-import/contactB.php
+
+# [System--Import CSV--Shipto]
+# module=lxo-import/shiptoB.php
+
+# [System--Import CSV--Parts]
+# module=lxo-import/partsB.php
+
 [System--Import CSV]
 module=menu.pl
 action=acc_menu
 target=acc_menu
 submenu=1
 
-[System--Import CSV--Customer]
-module=lxo-import/addressB.php
+[System--Import CSV--Customers and vendors]
+module=controller.pl
+action=CsvImport/new
+profile.type=customers_vendors
 
 [System--Import CSV--Contacts]
-module=lxo-import/contactB.php
+module=controller.pl
+action=CsvImport/new
+profile.type=contacts
 
 [System--Import CSV--Shipto]
-module=lxo-import/shiptoB.php
+module=controller.pl
+action=CsvImport/new
+profile.type=addresses
 
 [System--Import CSV--Parts]
-module=lxo-import/partsB.php
+module=controller.pl
+action=CsvImport/new
+profile.type=parts
 
 
 [System--Templates]
diff --git a/sql/Pg-upgrade2/csv_import_profiles.sql b/sql/Pg-upgrade2/csv_import_profiles.sql
new file mode 100644 (file)
index 0000000..2efac04
--- /dev/null
@@ -0,0 +1,24 @@
+-- @tag: csv_import_profiles
+-- @description: CSV-Import-Profile für Stammdaten
+-- @depends: release_2_6_1
+-- @charset: utf-8
+CREATE TABLE csv_import_profiles (
+       id SERIAL        NOT NULL,
+       name text        NOT NULL,
+       type varchar(20) NOT NULL,
+       is_default boolean DEFAULT FALSE,
+
+       PRIMARY KEY (id),
+       UNIQUE (name)
+);
+
+CREATE TABLE csv_import_profile_settings (
+       id SERIAL                     NOT NULL,
+       csv_import_profile_id integer NOT NULL,
+       key text                      NOT NULL,
+       value text,
+
+       PRIMARY KEY (id),
+       FOREIGN KEY (csv_import_profile_id) REFERENCES csv_import_profiles (id),
+       UNIQUE (csv_import_profile_id, key)
+);
diff --git a/t/helper/csv.t b/t/helper/csv.t
new file mode 100644 (file)
index 0000000..434626f
--- /dev/null
@@ -0,0 +1,296 @@
+use Test::More tests => 39;
+use SL::Dispatcher;
+use Data::Dumper;
+use utf8;
+
+use_ok 'SL::Helper::Csv';
+my $csv;
+
+$csv = SL::Helper::Csv->new(
+  file   => \"Kaffee\n",
+  header => [ 'description' ],
+);
+
+isa_ok $csv->_csv, 'Text::CSV_XS';
+isa_ok $csv->_io, 'IO::File';
+isa_ok $csv->parse, 'SL::Helper::Csv', 'parsing returns self';
+is_deeply $csv->get_data, [ { description => 'Kaffee' } ], 'simple case works';
+
+$csv->class('SL::DB::Part');
+
+is $csv->get_objects->[0]->description, 'Kaffee', 'get_object works';
+####
+
+SL::Dispatcher::pre_startup_setup();
+
+$::form = Form->new;
+$::myconfig{numberformat} = '1.000,00';
+$::myconfig{dateformat} = 'dd.mm.yyyy';
+$::locale = Locale->new('de');
+
+$csv = SL::Helper::Csv->new(
+  file   => \"Kaffee;0.12;12,2;1,5234\n",
+  header => [ 'description', 'sellprice', 'lastcost_as_number', 'listprice' ],
+  dispatch => { listprice => 'listprice_as_number' },
+  class  => 'SL::DB::Part',
+);
+$csv->parse;
+
+is $csv->get_objects->[0]->sellprice, 0.12, 'numeric attr works';
+is $csv->get_objects->[0]->lastcost, 12.2, 'attr helper works';
+is $csv->get_objects->[0]->listprice, 1.5234, 'dispatch works';
+
+#####
+
+
+$csv = SL::Helper::Csv->new(
+  file   => \<<EOL,
+description,sellprice,lastcost_as_number,listprice,
+Kaffee,0.12,'12,2','1,5234'
+EOL
+  sep_char => ',',
+  quote_char => "'",
+  dispatch => { listprice => 'listprice_as_number' },
+  class  => 'SL::DB::Part',
+);
+$csv->parse;
+is scalar @{ $csv->get_objects }, 1, 'auto header works';
+is $csv->get_objects->[0]->description, 'Kaffee', 'get_object works on auto header';
+
+#####
+
+
+$csv = SL::Helper::Csv->new(
+  file   => \<<EOL,
+;;description;sellprice;lastcost_as_number;
+#####;Puppy;Kaffee;0.12;12,2;1,5234
+EOL
+  class  => 'SL::DB::Part',
+);
+$csv->parse;
+is scalar @{ $csv->get_objects }, 1, 'bozo header doesn\'t blow things up';
+
+#####
+
+$csv = SL::Helper::Csv->new(
+  file   => \<<EOL,
+description;partnumber;sellprice;lastcost_as_number;
+Kaffee;;0.12;12,2;1,5234
+Beer;1123245;0.12;12,2;1,5234
+EOL
+  class  => 'SL::DB::Part',
+);
+$csv->parse;
+is scalar @{ $csv->get_objects }, 2, 'multiple objects work';
+is $csv->get_objects->[0]->description, 'Kaffee', 'first object';
+is $csv->get_objects->[1]->partnumber, '1123245', 'second object';
+
+####
+
+$csv = SL::Helper::Csv->new(
+  file   => \<<EOL,
+description;partnumber;sellprice;lastcost_as_number;
+Kaffee;;0.12;1,221.52
+Beer;1123245;0.12;1.5234
+EOL
+  numberformat => '1,000.00',
+  class  => 'SL::DB::Part',
+);
+$csv->parse;
+is $csv->get_objects->[0]->lastcost, '1221.52', 'formatnumber';
+
+######
+
+$csv = SL::Helper::Csv->new(
+  file   => \<<EOL,
+"description;partnumber;sellprice;lastcost_as_number;
+Kaffee;;0.12;1,221.52
+Beer;1123245;0.12;1.5234
+EOL
+  numberformat => '1,000.00',
+  class  => 'SL::DB::Part',
+);
+is $csv->parse, undef, 'broken csv header won\'t get parsed';
+
+######
+
+$csv = SL::Helper::Csv->new(
+  file   => \<<EOL,
+description;partnumber;sellprice;lastcost_as_number;
+"Kaf"fee";;0.12;1,221.52
+Beer;1123245;0.12;1.5234
+EOL
+  numberformat => '1,000.00',
+  class  => 'SL::DB::Part',
+);
+is $csv->parse, undef, 'broken csv content won\'t get parsed';
+is_deeply $csv->errors, [ '"Kaf"fee";;0.12;1,221.52'."\n", 2023, 'EIQ - QUO character not allowed', 5, 2 ], 'error';
+isa_ok( ($csv->errors)[0], 'SL::Helper::Csv::Error', 'Errors get objectified');
+
+####
+
+$csv = SL::Helper::Csv->new(
+  file   => \<<EOL,
+description;partnumber;sellprice;lastcost_as_number;wiener;
+Kaffee;;0.12;1,221.52;ja wiener
+Beer;1123245;0.12;1.5234;nein kein wieder
+EOL
+  numberformat => '1,000.00',
+  ignore_unknown_columns => 1,
+  class  => 'SL::DB::Part',
+);
+$csv->parse;
+is $csv->get_objects->[0]->lastcost, '1221.52', 'ignore_unkown_columns works';
+
+#####
+
+$csv = SL::Helper::Csv->new(
+  file   => \<<EOL,
+description;partnumber;sellprice;lastcost_as_number;buchungsgruppe;
+Kaffee;;0.12;1,221.52;Standard 7%
+Beer;1123245;0.12;1.5234;16 %
+EOL
+  numberformat => '1,000.00',
+  class  => 'SL::DB::Part',
+  profile => {
+    buchungsgruppe => "buchungsgruppen.description",
+  }
+);
+$csv->parse;
+isa_ok $csv->get_objects->[0]->buchungsgruppe, 'SL::DB::Buchungsgruppe', 'deep dispatch auto vivify works';
+is $csv->get_objects->[0]->buchungsgruppe->description, 'Standard 7%', '...and gets set correctly';
+
+
+#####
+
+$csv = SL::Helper::Csv->new(
+  file   => \<<EOL,
+description;partnumber;sellprice;lastcost_as_number;make_1;model_1;
+  Kaffee;;0.12;1,221.52;213;Chair 0815
+Beer;1123245;0.12;1.5234;
+EOL
+  numberformat => '1,000.00',
+  class  => 'SL::DB::Part',
+  profile => {
+    make_1 => "makemodels.0.make",
+    model_1 => "makemodels.0.model",
+  }
+);
+$csv->parse;
+my @mm = $csv->get_objects->[0]->makemodel;
+is scalar @mm,  1, 'one-to-many dispatch';
+is $csv->get_objects->[0]->makemodels->[0]->model, 'Chair 0815', '... and works';
+
+#####
+
+
+$csv = SL::Helper::Csv->new(
+  file   => \<<EOL,
+description;partnumber;sellprice;lastcost_as_number;make_1;model_1;make_2;model_2;
+ Kaffee;;0.12;1,221.52;213;Chair 0815;523;Table 15
+EOL
+  numberformat => '1,000.00',
+  class  => 'SL::DB::Part',
+  profile => {
+    make_1 => "makemodels.0.make",
+    model_1 => "makemodels.0.model",
+    make_2 => "makemodels.1.make",
+    model_2 => "makemodels.1.model",
+  }
+);
+$csv->parse;
+
+print Dumper($csv->errors);
+
+my @mm = $csv->get_objects->[0]->makemodel;
+is scalar @mm,  1, 'multiple one-to-many dispatch';
+is $csv->get_objects->[0]->makemodels->[0]->model, 'Chair 0815', '...check 1';
+is $csv->get_objects->[0]->makemodels->[0]->make, '213', '...check 2';
+is $csv->get_objects->[0]->makemodels->[1]->model, 'Table 15', '...check 3';
+is $csv->get_objects->[0]->makemodels->[1]->make, '523', '...check 4';
+
+######
+
+$csv = SL::Helper::Csv->new(
+  file   => \<<EOL,
+description;partnumber;sellprice;lastcost_as_number;buchungsgruppe;
+EOL
+  numberformat => '1,000.00',
+  class  => 'SL::DB::Part',
+  profile => {
+    buchungsgruppe => "buchungsgruppen.1.description",
+  }
+);
+is $csv->parse, undef, 'wrong profile gets rejected';
+is_deeply $csv->errors, [ 'buchungsgruppen.1.description', undef, "Profile path error. Indexed relationship is not OneToMany around here: 'buchungsgruppen.1'", undef ,0 ], 'error indicates wrong header';
+isa_ok( ($csv->errors)[0], 'SL::Helper::Csv::Error', 'Errors get objectified');
+
+####
+
+$csv = SL::Helper::Csv->new(
+  file   => \<<EOL,
+description;partnumber;sellprice;lastcost;wiener;
+Kaffee;;0.12;1,221.52;ja wiener
+Beer;1123245;0.12;1.5234;nein kein wieder
+EOL
+  numberformat => '1,000.00',
+  ignore_unknown_columns => 1,
+  strict_profile => 1,
+  class  => 'SL::DB::Part',
+  profile => {
+    lastcost => 'lastcost_as_number',
+  }
+);
+$csv->parse;
+is $csv->get_objects->[0]->lastcost, '1221.52', 'strict_profile with ignore';
+is $csv->get_objects->[0]->sellprice, undef,  'strict profile with ignore 2';
+
+####
+
+$csv = SL::Helper::Csv->new(
+  file   => \<<EOL,
+description;partnumber;sellprice;lastcost;wiener;
+Kaffee;;0.12;1,221.52;ja wiener
+Beer;1123245;0.12;1.5234;nein kein wieder
+EOL
+  numberformat => '1,000.00',
+  strict_profile => 1,
+  class  => 'SL::DB::Part',
+  profile => {
+    lastcost => 'lastcost_as_number',
+  }
+);
+$csv->parse;
+
+is_deeply( ($csv->errors)[0], [ 'description', undef, 'header field \'description\' is not recognized', undef, 0 ], 'strict_profile without ignore_columns throws error');
+
+#####
+
+$csv = SL::Helper::Csv->new(
+  file   => \"Kaffee",
+  header => [ 'description' ],
+  class  => 'SL::DB::Part',
+);
+$csv->parse;
+is_deeply $csv->get_data, [ { description => 'Kaffee' } ], 'eol bug at the end of files';
+
+#####
+
+$csv = SL::Helper::Csv->new(
+  file   => \"Description\nKaffee",
+  class  => 'SL::DB::Part',
+);
+$csv->parse;
+is_deeply $csv->get_data, [ { description => 'Kaffee' } ], 'case insensitive header from csv works';
+
+#####
+
+$csv = SL::Helper::Csv->new(
+  file   => \"Kaffee",
+  header => [ 'Description' ],
+  class  => 'SL::DB::Part',
+);
+$csv->parse;
+is_deeply $csv->get_data, [ { description => 'Kaffee' } ], 'case insensitive header as param works';
+
+# vim: ft=perl
diff --git a/templates/webpages/csv_import/_errors.html b/templates/webpages/csv_import/_errors.html
new file mode 100644 (file)
index 0000000..e96ac50
--- /dev/null
@@ -0,0 +1,20 @@
+[% USE LxERP %]
+
+<h3>[%- LxERP.t8('Errors') %]</h3>
+
+<p>[%- LxERP.t8('Found #1 errors.', SELF.errors.size) %]</p>
+
+<table>
+ <tr class="listheading">
+  <th>[%- LxERP.t8('Line and column') %]</th>
+  <th>[%- LxERP.t8('Block') %]</th>
+  <th>[%- LxERP.t8('Error') %]</th>
+ </tr>
+ [% FOREACH err = SELF.errors %]
+  <tr>
+   <td>[% err.4 %]:[% err.3 %]</td>
+   <td>[% err.0 %]</td>
+   <td>[% err.2 %]</td>
+  </tr>
+ [% END %]
+</table>
diff --git a/templates/webpages/csv_import/_form_customers_vendors.html b/templates/webpages/csv_import/_form_customers_vendors.html
new file mode 100644 (file)
index 0000000..c3bf9a5
--- /dev/null
@@ -0,0 +1,10 @@
+[% USE LxERP %]
+[% USE L %]
+
+<tr>
+ <th align="right">[%- LxERP.t8('Target table') %]:</th>
+ <td colspan="10">
+  [% opts = [ [ 'customer', LxERP.t8('Customers') ], [ 'vendor', LxERP.t8('Vendors') ] ] %]
+  [% L.select_tag('settings.table', L.options_for_select(opts, default => SELF.profile.get('table')), style => 'width: 300px') %]
+ </td>
+</tr>
diff --git a/templates/webpages/csv_import/_form_parts.html b/templates/webpages/csv_import/_form_parts.html
new file mode 100644 (file)
index 0000000..129113e
--- /dev/null
@@ -0,0 +1,52 @@
+[% USE LxERP %]
+[% USE L %]
+<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') ] ] %]
+  [% L.select_tag('settings.article_number_policy', L.options_for_select(opts, default => SELF.profile.get('article_number_policy')), style => 'width: 300px') %]
+ </td>
+</tr>
+
+<tr>
+ <th align="right">[%- LxERP.t8('Sellprice significant places') %]:</th>
+ <td colspan="10">
+  [% L.select_tag('settings.sellprice_places', L.options_for_select([ 0, 1, 2, 3, 4, 5 ], default => SELF.profile.get('sellprice_places')), style => 'width: 300px') %]
+ </td>
+</tr>
+
+<tr>
+ <th align="right">[%- LxERP.t8('Sellprice adjustment') %]:</th>
+ <td colspan="10">
+  [% L.input_tag('settings.sellprice_adjustment', LxERP.format_amount(SELF.profile.get('sellprice_adjustment')), size => "5") %]
+  [% opts = [ [ 'percent', LxERP.t8('percental') ], [ 'absolute', LxERP.t8('absolute') ] ] %]
+  [% L.select_tag('settings.sellprice_adjustment_type', L.options_for_select(opts, default => SELF.profile.get('sellprice_adjustment_type'))) %]
+ </td>
+</tr>
+
+<tr>
+ <th align="right">[%- LxERP.t8('Mark as shop article if column missing') %]:</th>
+ <td colspan="10">
+  [% opts = [ [ '1', LxERP.t8('yes') ], [ '0', LxERP.t8('no') ] ] %]
+  [% L.select_tag('settings.shoparticle_if_missing', L.options_for_select(opts, default => SELF.profile.get('shoparticle_if_missing')), style => 'width: 300px') %]
+ </td>
+</tr>
+
+<tr>
+ <th align="right">[%- LxERP.t8('Type') %]:</th>
+ <td colspan="10">
+  [% opts = [ [ 'part', LxERP.t8('Parts') ], [ 'service', LxERP.t8('Services') ], [ 'mixed', LxERP.t8('Mixed (requires column "type")') ] ] %]
+  [% L.select_tag('settings.parts_type', L.options_for_select(opts, default => SELF.profile.get('parts_type')), style => 'width: 300px') %]
+ </td>
+</tr>
+
+<tr>
+ <th align="right" valign="top">[%- LxERP.t8('Default buchungsgruppe') %]:</th>
+ <td colspan="10" valign="top">
+  [% opts = L.options_for_select(SELF.all_buchungsgruppen, title => 'description', default => SELF.profile.get('default_buchungsgruppe')) %]
+  [% L.select_tag('settings.default_buchungsgruppe', opts, style => 'width: 300px') %]
+  <br>
+  [% opts = [ [ 'never', LxERP.t8('Do not set default buchungsgruppe') ], [ 'all', LxERP.t8('Apply to all parts') ], [ 'missing', LxERP.t8('Apply to parts without buchungsgruppe') ] ] %]
+  [% L.select_tag('settings.apply_buchungsgruppe', L.options_for_select(opts, default => SELF.profile.get('apply_buchungsgruppe')), style => 'width: 300px') %]
+ </td>
+</tr>
diff --git a/templates/webpages/csv_import/_preview.html b/templates/webpages/csv_import/_preview.html
new file mode 100644 (file)
index 0000000..fe088e2
--- /dev/null
@@ -0,0 +1,46 @@
+[% USE HTML %]
+[% USE LxERP %]
+
+[% IF SELF.data.size %]
+ <h3>
+  [%- IF SELF.import_status == 'tested' %]
+   [%- LxERP.t8('Import preview') %]
+  [%- ELSE %]
+   [%- LxERP.t8('Import result') %]
+  [%- END %]
+ </h3>
+
+ <table>
+  <tr class="listheading">
+   [%- FOREACH column = SELF.info_headers.headers %]
+    <th>[%- HTML.escape(column) %]</th>
+   [%- END %]
+   [%- FOREACH column = SELF.headers.headers %]
+    <th>[%- HTML.escape(column) %]</th>
+   [%- END %]
+   [%- FOREACH column = SELF.raw_data_headers.headers %]
+    <th>[%- HTML.escape(column) %]</th>
+   [%- END %]
+   <th>[%- LxERP.t8('Notes') %]</th>
+  </tr>
+
+  [%- FOREACH row = SELF.data %]
+  <tr class="[% IF row.errors.size %]redrow[% ELSE %]listrow[% END %][% loop.count % 2 %]">
+   [%- FOREACH method = SELF.info_headers.methods %]
+    <td>[%- HTML.escape(row.info_data.$method) %]</td>
+   [%- END %]
+   [%- FOREACH method = SELF.headers.methods %]
+    <td>[%- HTML.escape(row.object.$method) %]</td>
+   [%- END %]
+   [%- FOREACH method = SELF.raw_data_headers.headers %]
+    <td>[%- HTML.escape(row.raw_data.$method) %]</td>
+   [%- END %]
+   <td>
+    [%- FOREACH error = row.errors %][%- HTML.escape(error) %][% UNLESS loop.last %]<br>[%- END %][%- END %]
+    [%- FOREACH info  = row.information %][% IF !loop.first || row.errors.size %]<br>[%- END %][%- HTML.escape(info) %][%- END %]
+   </td>
+  </tr>
+  [%- END %]
+
+ </table>
+[%- END %]
diff --git a/templates/webpages/csv_import/_result.html b/templates/webpages/csv_import/_result.html
new file mode 100644 (file)
index 0000000..b67bb7f
--- /dev/null
@@ -0,0 +1,9 @@
+[% USE LxERP %]
+
+<h3>[%- LxERP.t8('Import summary') %]</h3>
+
+[%- IF SELF.import_status == 'imported' %]
+ <p>[%- LxERP.t8('#1 of #2 importable objects were imported.', SELF.num_imported, SELF.num_importable || 0) %]</p>
+[%- ELSE %]
+ <p>[%- LxERP.t8('Found #1 objects of which #2 can be imported.', SELF.data.size || 0, SELF.num_importable || 0) %]</p>
+[%- END %]
diff --git a/templates/webpages/csv_import/form.html b/templates/webpages/csv_import/form.html
new file mode 100644 (file)
index 0000000..b146465
--- /dev/null
@@ -0,0 +1,219 @@
+[% USE HTML %][% USE LxERP %][% USE L %]
+<body>
+
+ <div class="listtop">[% FORM.title %]</div>
+
+ [%- INCLUDE 'common/flash.html' %]
+
+ <form method="post" action="controller.pl" enctype="multipart/form-data">
+  [% L.hidden_tag('action', 'CsvImport/dispatch') %]
+  [% L.hidden_tag('profile.type', SELF.profile.type) %]
+
+  <h2>[%- LxERP.t8('Import profiles') %]</h2>
+
+  <table>
+   [%- IF SELF.profile.id %]
+    <tr>
+     <th align="right">[%- LxERP.t8('Current profile') %]:</th>
+     <td>[%- HTML.escape(SELF.profile.name) %]</td>
+    </tr>
+   [%- END %]
+
+   [%- IF SELF.all_profiles.size %]
+    <tr>
+     <th align="right">[%- LxERP.t8('Existing profiles') %]:</th>
+     <td>
+      [% L.select_tag('profile.id', L.options_for_select(SELF.all_profiles, title => 'name', default => SELF.profile.id), style => 'width: 300px') %]
+     </td>
+     <td>
+      [% L.submit_tag('action_new', LxERP.t8('Load profile')) %]
+      [% L.submit_tag('action_destroy', LxERP.t8('Delete profile'), confirm => LxERP.t8('Do you really want to delete this object?')) %]
+     </td>
+    </tr>
+   [%- END %]
+
+   <tr>
+    <th align="right" valign="top">[%- LxERP.t8('Save settings as') %]:</th>
+    <td valign="top">
+     [% L.input_tag('profile.name', '', style => 'width: 300px') %]
+     <br>
+     [% L.checkbox_tag('profile.is_default', label => LxERP.t8('Make default profile')) %]
+    </td>
+    <td valign="top">[% L.submit_tag('action_save', LxERP.t8('Save profile')) %]</td>
+   </tr>
+  </table>
+
+  <hr>
+
+  <h2>[%- LxERP.t8('Help on column names') %]</h2>
+
+  <div class="help_toggle">
+   <a href="#" onClick="javascript:$('.help_toggle').toggle()">[% LxERP.t8("Show help text") %]</a>
+  </div>
+
+  <div class="help_toggle" style="display:none">
+   <p><a href="#" onClick="javascript:$('.help_toggle').toggle()">[% LxERP.t8("Hide help text") %]</a></p>
+
+   <table>
+    <tr class="listheading">
+     <th>[%- LxERP.t8('Column name') %]</th>
+     <th>[%- LxERP.t8('Meaning') %]</th>
+    </tr>
+
+    [%- FOREACH row = SELF.displayable_columns %]
+     <tr class="listrow[% loop.count % 2 %]">
+      <td>[%- HTML.escape(row.name) %]</td>
+      <td>[%- HTML.escape(row.description) %]</td>
+     </tr>
+    [%- END %]
+   </table>
+
+[%- IF SELF.type == 'contacts' %]
+   <p>
+    [%- LxERP.t8('At least one of the columns #1, customer, customernumber, vendor, vendornumber (depending on the target table) is required for matching the entry to an existing customer or vendor.', 'cp_cv_id') %]
+   </p>
+
+[%- ELSIF SELF.type == 'addresses' %]
+   <p>
+    [%- LxERP.t8('At least one of the columns #1, customer, customernumber, vendor, vendornumber (depending on the target table) is required for matching the entry to an existing customer or vendor.', 'trans_id') %]
+   </p>
+
+[%- ELSIF SELF.type == 'parts' %]
+   <p>
+    [%- LxERP.t8("If the article type is set to 'mixed' then a column called 'type' must be present.") %]
+    [% LxERP.t8("Type can be either 'part' or 'service'.") %]
+   </p>
+[%- END %]
+
+   <p>
+    [%- L.submit_tag('action_download_sample', LxERP.t8('Download sample file')) %]
+   </p>
+
+  </div>
+
+  <hr>
+
+  <h2>[%- LxERP.t8('Settings') %]</h2>
+
+  <table>
+   <tr>
+    <th align="right">[%- LxERP.t8('Number Format') %]:</th>
+    <td colspan="10">
+     [% SET options = L.options_for_select([ '1.000,00', '1000,00', '1,000.00', '1000.00' ], default => SELF.profile.get('numberformat')) %]
+     [% L.select_tag('settings.numberformat', options, style => 'width: 300px') %]
+    </td>
+   </tr>
+
+   <tr>
+    <th align="right">[%- LxERP.t8('Charset') %]:</th>
+    <td colspan="10">[% L.select_tag('settings.charset', L.options_for_select(SELF.all_charsets, default => SELF.profile.get('charset')), style => 'width: 300px') %]</td>
+   </tr>
+
+   <tr>
+    <th align="right">[%- LxERP.t8('Separator') %]:</th>
+    [% SET custom_sep_char = SELF.sep_char %]
+    [% FOREACH entry = SELF.all_sep_chars %]
+     <td>
+      [% IF SELF.sep_char == entry.first %] [% SET custom_sep_char = '' %] [%- END %]
+      [% L.radio_button_tag('sep_char', value => entry.first, label => entry.last, checked => SELF.sep_char == entry.first) %]
+     </td>
+    [%- END %]
+
+    <td>
+     [% L.radio_button_tag('sep_char', value => 'custom', checked => custom_sep_char != '') %]
+     [% L.input_tag('custom_sep_char', custom_sep_char, size => 3, maxlength => 1) %]
+    </td>
+   </tr>
+
+   <tr>
+    <th align="right">[%- LxERP.t8('Quote character') %]:</th>
+    [% SET custom_quote_char = SELF.quote_char %]
+    [% FOREACH entry = SELF.all_quote_chars %]
+     <td>
+      [% IF SELF.quote_char == entry.first %] [% SET custom_quote_char = '' %] [%- END %]
+      [% L.radio_button_tag('quote_char', value => entry.first, label => entry.last, checked => SELF.quote_char == entry.first) %]
+     </td>
+    [%- END %]
+
+    <td>
+     [% L.radio_button_tag('quote_char', value => 'custom', checked => custom_quote_char != '') %]
+     [% L.input_tag('custom_quote_char', custom_quote_char, size => 3, maxlength => 1) %]
+    </td>
+   </tr>
+
+   <tr>
+    <th align="right">[%- LxERP.t8('Escape character') %]:</th>
+    [% SET custom_escape_char = SELF.escape_char %]
+    [% FOREACH entry = SELF.all_escape_chars %]
+     <td>
+      [% IF SELF.escape_char == entry.first %] [% SET custom_escape_char = '' %] [%- END %]
+      [% L.radio_button_tag('escape_char', value => entry.first, label => entry.last, checked => SELF.escape_char == entry.first) %]
+     </td>
+    [%- END %]
+
+    <td>
+     [% L.radio_button_tag('escape_char', value => 'custom', checked => custom_escape_char != '') %]
+     [% L.input_tag('custom_escape_char', custom_escape_char, size => 3, maxlength => 1) %]
+    </td>
+   </tr>
+
+   <tr>
+    <th align="right">[%- LxERP.t8('Check for duplicates') %]:</th>
+    <td colspan="10">
+     [% opts = [ [ 'no_check',  LxERP.t8('Do not check for duplicates') ],
+                 [ 'check_csv', LxERP.t8('Discard duplicate entries in CSV file') ],
+                 [ 'check_db',  LxERP.t8('Discard entries with duplicates in database or CSV file') ] ] %]
+     [% L.select_tag('settings.duplicates', L.options_for_select(opts, default => SELF.profile.get('duplicates')), style => 'width: 300px') %]
+    </td>
+   </tr>
+
+[%- IF SELF.type == 'parts' %]
+ [%- INCLUDE 'csv_import/_form_parts.html' %]
+[%- ELSIF SELF.type == 'customers_vendors' %]
+ [%- INCLUDE 'csv_import/_form_customers_vendors.html' %]
+[%- END %]
+
+   <tr>
+    <th align="right">[%- LxERP.t8('Import file') %]:</th>
+    <td colspan="10">[% L.input_tag('file', '', type => 'file', accept => '*') %]</td>
+   </tr>
+
+   [%- IF SELF.file.exists %]
+    <tr>
+     <th align="right">[%- LxERP.t8('Existing file on server') %]:</th>
+     <td colspan="10">[%- LxERP.t8('Uploaded on #1, size #2 kB', SELF.file.displayable_mtime, LxERP.format_amount(SELF.file.size / 1024, 2)) %]</td>
+    </tr>
+   [%- END %]
+
+  </table>
+
+  [% L.submit_tag('action_test', LxERP.t8('Test and preview')) %]
+  [% IF (SELF.import_status == 'tested') && SELF.num_importable %]
+   [% L.submit_tag('action_import', LxERP.t8('Import')) %]
+  [%- END %]
+
+ </form>
+
+ [%- IF SELF.import_status %]
+  [%- IF SELF.errors %]
+   [%- PROCESS 'csv_import/_errors.html' %]
+  [%- END %]
+
+  [%- PROCESS 'csv_import/_result.html' %]
+  [%- PROCESS 'csv_import/_preview.html' %]
+ [%- END %]
+
+ <script type="text/javascript">
+  <!--
+    $(document).ready(function() {
+      $('#action_save').click(function() {
+        if ($('#profile_name').attr('value') != '')
+          return true;
+        alert('[% LxERP.t8('Please enter a profile name.') %]');
+        return false;
+      })
+    });
+    -->
+ </script>
+</body>
+</html>