Merge pull request #30 from rebootl/csv-import-script-fix
authorBernd Bleßmann <bernd@kivitendo-premium.de>
Mon, 10 May 2021 18:58:04 +0000 (20:58 +0200)
committerGitHub <noreply@github.com>
Mon, 10 May 2021 18:58:04 +0000 (20:58 +0200)
CSV Import Shell Script parameter ergänzt sowie Ausgabeprüfung behoben

118 files changed:
SL/AM.pm
SL/ARAP.pm
SL/BackgroundJob/ALL.pm
SL/BackgroundJob/ConvertTimeRecordings.pm [new file with mode: 0644]
SL/BackgroundJob/CreatePeriodicInvoices.pm
SL/ClientJS.pm
SL/Controller/CsvImport.pm
SL/Controller/CsvImport/BankTransaction.pm
SL/Controller/CsvImport/Base.pm
SL/Controller/CsvImport/BaseMulti.pm
SL/Controller/CsvImport/CustomerVendor.pm
SL/Controller/CsvImport/Inventory.pm
SL/Controller/CsvImport/Part.pm
SL/Controller/CsvImport/Project.pm
SL/Controller/CsvImport/Shipto.pm
SL/Controller/File.pm
SL/Controller/Order.pm
SL/Controller/Part.pm
SL/Controller/Project.pm
SL/Controller/ShopOrder.pm
SL/Controller/SimpleSystemSetting.pm
SL/Controller/TimeRecording.pm [new file with mode: 0644]
SL/DB/DeliveryOrder.pm
SL/DB/Helper/ALL.pm
SL/DB/Helper/FlattenToForm.pm
SL/DB/Helper/Mappings.pm
SL/DB/Helper/PriceTaxCalculator.pm
SL/DB/Manager/TimeRecording.pm [new file with mode: 0644]
SL/DB/Manager/TimeRecordingArticle.pm [new file with mode: 0644]
SL/DB/MetaSetup/CustomVariableConfig.pm
SL/DB/MetaSetup/Default.pm
SL/DB/MetaSetup/OrderItem.pm
SL/DB/MetaSetup/TimeRecording.pm [new file with mode: 0644]
SL/DB/MetaSetup/TimeRecordingArticle.pm [new file with mode: 0644]
SL/DB/Order.pm
SL/DB/ShopOrder.pm
SL/DB/TimeRecording.pm [new file with mode: 0644]
SL/DB/TimeRecordingArticle.pm [new file with mode: 0644]
SL/DO.pm
SL/Dev/ALL.pm
SL/Dev/TimeRecording.pm [new file with mode: 0644]
SL/Form.pm
SL/Helper/ISO3166.pm
SL/Helper/UserPreferences/TimeRecording.pm [new file with mode: 0644]
SL/IC.pm
SL/IS.pm
SL/InstallationCheck.pm
SL/OE.pm
SL/Presenter/Project.pm
SL/ReportGenerator.pm
SL/ShopConnector/Base.pm
SL/ShopConnector/Shopware.pm
bin/mozilla/am.pl
bin/mozilla/ar.pl
bin/mozilla/do.pl
bin/mozilla/gl.pl
bin/mozilla/io.pl
bin/mozilla/ir.pl
bin/mozilla/oe.pl
bin/mozilla/rp.pl
bin/mozilla/wh.pl
doc/changelog
js/autocomplete_project.js
js/kivi.File.js
js/kivi.Order.js
js/kivi.SalesPurchase.js
js/kivi.ShopOrder.js
js/kivi.TimeRecording.js [new file with mode: 0644]
js/kivi.Validator.js
js/locale/de.js
js/locale/en.js
locale/de/all
locale/en/all
menus/user/10-time-recording.yaml [new file with mode: 0644]
sql/Pg-upgrade2-auth/right_time_recording.sql [new file with mode: 0644]
sql/Pg-upgrade2-auth/rights_time_recording_show_edit_all.sql [new file with mode: 0644]
sql/Pg-upgrade2/add_transfer_doc_interval.sql [new file with mode: 0644]
sql/Pg-upgrade2/custom_variables_add_edit_position.sql [new file with mode: 0644]
sql/Pg-upgrade2/cvars_remove_dublicate_entries.pl [new file with mode: 0644]
sql/Pg-upgrade2/file_storage_project.sql [new file with mode: 0644]
sql/Pg-upgrade2/orderitems_optional.sql [new file with mode: 0644]
sql/Pg-upgrade2/time_recordings.sql [new file with mode: 0644]
sql/Pg-upgrade2/time_recordings2.sql [new file with mode: 0644]
sql/Pg-upgrade2/time_recordings_add_order.sql [new file with mode: 0644]
sql/Pg-upgrade2/time_recordings_articles.sql [new file with mode: 0644]
sql/Pg-upgrade2/time_recordings_date_duration.sql [new file with mode: 0644]
sql/Pg-upgrade2/time_recordings_remove_type.sql [new file with mode: 0644]
t/background_job/convert_time_recordings.t [new file with mode: 0644]
t/controllers/csvimport/customervendor.t [new file with mode: 0644]
t/db/time_recordig.t [new file with mode: 0644]
t/db_helper/price_tax_calculator.t
templates/print/RB/deutsch.tex
templates/print/RB/english.tex
templates/print/RB/sales_order.tex
templates/print/RB/sales_quotation.tex
templates/webpages/am/config.html
templates/webpages/client_config/_warehouse.html
templates/webpages/common/_send_email_dialog.html
templates/webpages/custom_variable_config/form.html
templates/webpages/do/stock_in_form.html
templates/webpages/file/list.html
templates/webpages/is/form_header.html
templates/webpages/mass_delivery_order_print/_print_status.html
templates/webpages/order/tabs/_second_row.html
templates/webpages/part/_assembly.html
templates/webpages/part/_basic_data.html
templates/webpages/project/form.html
templates/webpages/project/test_page.html
templates/webpages/report_generator/html_report.html
templates/webpages/shop_order/_get_one.html [new file with mode: 0644]
templates/webpages/shop_order/list.html
templates/webpages/simple_system_setting/_time_recording_article_form.html [new file with mode: 0644]
templates/webpages/time_recording/_filter.html [new file with mode: 0644]
templates/webpages/time_recording/form.html [new file with mode: 0644]
templates/webpages/time_recording/report_bottom.html [new file with mode: 0644]
templates/webpages/time_recording/report_top.html [new file with mode: 0644]
templates/webpages/wh/journal_filter.html
templates/webpages/wh/report_filter.html

index b7c6003..57e6b44 100644 (file)
--- a/SL/AM.pm
+++ b/SL/AM.pm
@@ -54,6 +54,7 @@ use SL::DB;
 use SL::GenericTranslations;
 use SL::Helper::UserPreferences::PositionsScrollbar;
 use SL::Helper::UserPreferences::PartPickerSearch;
+use SL::Helper::UserPreferences::TimeRecording;
 use SL::Helper::UserPreferences::UpdatePositions;
 
 use strict;
@@ -546,6 +547,10 @@ sub positions_show_update_button {
   SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
 }
 
+sub time_recording_use_duration {
+  SL::Helper::UserPreferences::TimeRecording->new()->get_use_duration();
+}
+
 sub save_preferences {
   $main::lxdebug->enter_sub();
 
@@ -583,6 +588,9 @@ sub save_preferences {
   if (exists $form->{positions_show_update_button}) {
     SL::Helper::UserPreferences::UpdatePositions->new()->store_show_update_button($form->{positions_show_update_button})
   }
+  if (exists $form->{time_recording_use_duration}) {
+    SL::Helper::UserPreferences::TimeRecording->new()->store_use_duration($form->{time_recording_use_duration})
+  }
 
   $main::lxdebug->leave_sub();
 
index 1d044ea..a2d7864 100644 (file)
@@ -67,7 +67,8 @@ sub close_orders_if_billed {
   my $q_ordered = qq|SELECT oi.parts_id, oi.qty, oi.unit, p.unit AS partunit
                       FROM orderitems oi
                       LEFT JOIN parts p ON (oi.parts_id = p.id)
-                      WHERE oi.trans_id = ?|;
+                      WHERE oi.trans_id = ?
+                      AND not oi.optional|;
   my $h_ordered = prepare_query($form, $dbh, $q_ordered);
 
   my @close_oe_ids;
index 938e62e..2e637cd 100644 (file)
@@ -6,6 +6,7 @@ use SL::BackgroundJob::Base;
 use SL::BackgroundJob::BackgroundJobCleanup;
 use SL::BackgroundJob::CleanBackgroundJobHistory;
 use SL::BackgroundJob::CloseProjectsBelongingToClosedSalesOrders;
+use SL::BackgroundJob::ConvertTimeRecordings;
 use SL::BackgroundJob::CreatePeriodicInvoices;
 use SL::BackgroundJob::FailedBackgroundJobsReport;
 
diff --git a/SL/BackgroundJob/ConvertTimeRecordings.pm b/SL/BackgroundJob/ConvertTimeRecordings.pm
new file mode 100644 (file)
index 0000000..ff8c79b
--- /dev/null
@@ -0,0 +1,500 @@
+package SL::BackgroundJob::ConvertTimeRecordings;
+
+use strict;
+
+use parent qw(SL::BackgroundJob::Base);
+
+use SL::DB::DeliveryOrder;
+use SL::DB::Part;
+use SL::DB::Project;
+use SL::DB::TimeRecording;
+use SL::Helper::ShippedQty;
+use SL::Locale::String qw(t8);
+
+use DateTime;
+use List::Util qw(any);
+
+sub create_job {
+  $_[0]->create_standard_job('7 3 1 * *'); # every first day of month at 03:07
+}
+use Rose::Object::MakeMethods::Generic (
+ 'scalar'                => [ qw(params) ],
+);
+
+#
+# If job does not throw an error,
+# success in background_job_histories is 'success'.
+# It is 'failure' otherwise.
+#
+# Return value goes to result in background_job_histories.
+#
+sub run {
+  my ($self, $db_obj) = @_;
+
+  $self->initialize_params($db_obj->data_as_hash) if $db_obj;
+
+  $self->{$_} = [] for qw(job_errors);
+
+  my %customer_where;
+  %customer_where = ('customer_id' => $self->params->{customer_ids}) if scalar @{ $self->params->{customer_ids} };
+
+  my $time_recordings = SL::DB::Manager::TimeRecording->get_all(where => [date        => { ge_lt => [ $self->params->{from_date}, $self->params->{to_date} ]},
+                                                                          or          => [booked => 0, booked => undef],
+                                                                          '!duration' => 0,
+                                                                          '!duration' => undef,
+                                                                          %customer_where]);
+
+  return t8('No time recordings to convert') if scalar @$time_recordings == 0;
+
+  my @donumbers;
+
+  if ($self->params->{link_order}) {
+    my %time_recordings_by_order_id;
+    my %orders_by_order_id;
+    foreach my $tr (@$time_recordings) {
+      my $order = $self->get_order_for_time_recording($tr);
+      next if !$order;
+      push @{ $time_recordings_by_order_id{$order->id} }, $tr;
+      $orders_by_order_id{$order->id} ||= $order;
+    }
+    @donumbers = $self->convert_with_linking(\%time_recordings_by_order_id, \%orders_by_order_id);
+
+  } else {
+    @donumbers = $self->convert_without_linking($time_recordings);
+  }
+
+  my $msg  = t8('Number of delivery orders created:');
+  $msg    .= ' ';
+  $msg    .= scalar @donumbers;
+  $msg    .= ' (';
+  $msg    .= join ', ', @donumbers;
+  $msg    .= ').';
+  # die if errors exists
+  if (@{ $self->{job_errors} }) {
+    $msg  .= ' ' . t8('The following errors occurred:');
+    $msg  .= ' ';
+    $msg  .= join "\n", @{ $self->{job_errors} };
+    die $msg . "\n";
+  }
+  return $msg;
+}
+
+# helper
+sub initialize_params {
+  my ($self, $data) = @_;
+
+  # valid parameters with default values
+  my %valid_params = (
+    from_date           => DateTime->new( day => 1,    month => DateTime->today_local->month, year => DateTime->today_local->year)->subtract(months => 1)->to_kivitendo,
+    to_date             => DateTime->last_day_of_month(month => DateTime->today_local->month, year => DateTime->today_local->year)->subtract(months => 1)->to_kivitendo,
+    customernumbers     => [],
+    override_part_id    => undef,
+    default_part_id     => undef,
+    override_project_id => undef,
+    default_project_id  => undef,
+    rounding            => 1,
+    link_order          => 0,
+  );
+
+
+  # check user input param names
+  foreach my $param (keys %$data) {
+    die "Not a valid parameter: $param" unless exists $valid_params{$param};
+  }
+
+  # set defaults
+  $self->params(
+    { map { ($_ => $data->{$_} // $valid_params{$_}) } keys %valid_params }
+  );
+
+
+  # convert date from string to object
+  my $from_date;
+  my $to_date;
+  $from_date = DateTime->from_kivitendo($self->params->{from_date});
+  $to_date   = DateTime->from_kivitendo($self->params->{to_date});
+  # DateTime->from_kivitendo returns undef if the string cannot be parsed. Therefore test the result.
+  die 'Cannot convert date from string "' . $self->params->{from_date} . '"' if !$from_date;
+  die 'Cannot convert date to string "'   . $self->params->{to_date}   . '"' if !$to_date;
+
+  $to_date->add(days => 1); # to get all from the to_date, because of the time part (15.12.2020 23.59 > 15.12.2020)
+
+  $self->params->{from_date} = $from_date;
+  $self->params->{to_date}   = $to_date;
+
+
+  # check if customernumbers are valid
+  die 'Customer numbers must be given in an array' if 'ARRAY' ne ref $self->params->{customernumbers};
+
+  my $customers = [];
+  if (scalar @{ $self->params->{customernumbers} }) {
+    $customers = SL::DB::Manager::Customer->get_all(where => [ customernumber => $self->params->{customernumbers},
+                                                               or             => [obsolete => undef, obsolete => 0] ]);
+  }
+  die 'Not all customer numbers are valid' if scalar @$customers != scalar @{ $self->params->{customernumbers} };
+
+  # return customer ids
+  $self->params->{customer_ids} = [ map { $_->id } @$customers ];
+
+
+  # check part
+  if ($self->params->{override_part_id} && !SL::DB::Manager::Part->find_by(id => $self->params->{override_part_id},
+                                                                           or => [obsolete => undef, obsolete => 0])) {
+    die 'No valid part found by given override part id';
+  }
+  if ($self->params->{default_part_id} && !SL::DB::Manager::Part->find_by(id => $self->params->{default_part_id},
+                                                                           or => [obsolete => undef, obsolete => 0])) {
+    die 'No valid part found by given default part id';
+  }
+
+
+  # check project
+  if ($self->params->{override_project_id} && !SL::DB::Manager::Project->find_by(id => $self->params->{override_project_id},
+                                                                                 active => 1, valid => 1)) {
+    die 'No valid project found by given override project id';
+  }
+  if ($self->params->{default_project_id} && !SL::DB::Manager::Project->find_by(id => $self->params->{default_project_id},
+                                                                                active => 1, valid => 1)) {
+    die 'No valid project found by given default project id';
+  }
+
+  return $self->params;
+}
+
+sub convert_without_linking {
+  my ($self, $time_recordings) = @_;
+
+  my %time_recordings_by_customer_id;
+  push @{ $time_recordings_by_customer_id{$_->customer_id} }, $_ for @$time_recordings;
+
+  my %convert_params = (
+    rounding         => $self->params->{rounding},
+    override_part_id => $self->params->{override_part_id},
+    default_part_id  => $self->params->{default_part_id},
+  );
+
+  my @donumbers;
+  foreach my $customer_id (keys %time_recordings_by_customer_id) {
+    my $do;
+    if (!eval {
+      $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_customer_id{$customer_id}, %convert_params);
+      1;
+    }) {
+      $self->log_error("creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
+    }
+
+    if ($do) {
+      if (!SL::DB->client->with_transaction(sub {
+        $do->save;
+        $_->update_attributes(booked => 1) for @{$time_recordings_by_customer_id{$customer_id}};
+        1;
+      })) {
+        $self->log_error('saving delivery order failed for time recording ids ' . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
+      } else {
+        push @donumbers, $do->donumber;
+      }
+    }
+  }
+
+  return @donumbers;
+}
+
+sub convert_with_linking {
+  my ($self, $time_recordings_by_order_id, $orders_by_order_id) = @_;
+
+  my %convert_params = (
+    rounding        => $self->params->{rounding},
+    override_part_id => $self->params->{override_part_id},
+    default_part_id  => $self->params->{default_part_id},
+  );
+
+  my @donumbers;
+  foreach my $related_order_id (keys %$time_recordings_by_order_id) {
+    my $related_order = $orders_by_order_id->{$related_order_id};
+    my $do;
+    if (!eval {
+      $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_order_id->{$related_order_id}, related_order => $related_order, %convert_params);
+      1;
+    }) {
+      $self->log_error("creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
+    }
+
+    if ($do) {
+      if (!SL::DB->client->with_transaction(sub {
+        $do->save;
+        $_->update_attributes(booked => 1) for @{$time_recordings_by_order_id->{$related_order_id}};
+
+        $related_order->link_to_record($do);
+
+        # TODO extend link_to_record for items, otherwise long-term no d.r.y.
+        foreach my $item (@{ $do->items }) {
+          foreach (qw(orderitems)) {
+            if ($item->{"converted_from_${_}_id"}) {
+              die unless $item->{id};
+              RecordLinks->create_links('mode'       => 'ids',
+                                        'from_table' => $_,
+                                        'from_ids'   => $item->{"converted_from_${_}_id"},
+                                        'to_table'   => 'delivery_order_items',
+                                        'to_id'      => $item->{id},
+              ) || die;
+              delete $item->{"converted_from_${_}_id"};
+            }
+          }
+        }
+
+        # update delivered and item's ship for related order
+        my $helper = SL::Helper::ShippedQty->new->calculate($related_order)->write_to_objects;
+        $related_order->delivered($related_order->{delivered});
+        $_->ship($_->{shipped_qty}) for @{$related_order->items};
+        $related_order->save(cascade => 1);
+
+        1;
+      })) {
+        $self->log_error('saving delivery order failed for time recording ids ' . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
+
+      } else {
+        push @donumbers, $do->donumber;
+      }
+    }
+  }
+
+  return @donumbers;
+}
+
+sub get_order_for_time_recording {
+  my ($self, $tr) = @_;
+
+  my $orders;
+
+  if (!$tr->order_id) {
+    # check project
+    my $project_id;
+    $project_id   = $self->params->{override_project_id};
+    $project_id ||= $tr->project_id;
+    $project_id ||= $self->params->{default_project_id};
+
+    if (!$project_id) {
+      $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no project id');
+      return;
+    }
+
+    my $project = SL::DB::Project->load_cached($project_id);
+
+    if (!$project) {
+      $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project not found');
+      return;
+    }
+    if (!$project->active || !$project->valid) {
+      $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project not active or not valid');
+      return;
+    }
+    if ($project->customer_id && $project->customer_id != $tr->customer_id) {
+      $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project customer does not match customer of time recording');
+      return;
+    }
+
+    $orders = SL::DB::Manager::Order->get_all(where        => [customer_id      => $tr->customer_id,
+                                                               or               => [quotation => undef, quotation => 0],
+                                                               globalproject_id => $project_id, ],
+                                              with_objects => ['orderitems']);
+
+  } else {
+    # order_id given
+    my $order = SL::DB::Manager::Order->find_by(id => $tr->order_id);
+    push @$orders, $order if $order;
+  }
+
+  if (!scalar @$orders) {
+    $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no order found');
+    return;
+  }
+
+  # check part
+  my $part_id;
+  $part_id   = $self->params->{override_part_id};
+  $part_id ||= $tr->part_id;
+  $part_id ||= $self->params->{default_part_id};
+
+  if (!$part_id) {
+    $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no part id');
+    return;
+  }
+  my $part = SL::DB::Part->load_cached($part_id);
+  if (!$part->unit_obj->is_time_based) {
+    $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : part unit is not time based');
+    return;
+  }
+
+  my @matching_orders;
+  foreach my $order (@$orders) {
+    if (any { $_->parts_id == $part_id } @{ $order->items_sorted }) {
+      push @matching_orders, $order;
+    }
+  }
+
+  if (1 != scalar @matching_orders) {
+    $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no or more than one orders do match');
+    return;
+  }
+
+  my $matching_order = $matching_orders[0];
+
+  if (!$matching_order->is_sales) {
+    $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : found order is not a sales order');
+    return;
+  }
+
+  if ($matching_order->customer_id != $tr->customer_id) {
+    $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : customer of order does not match customer of time recording');
+    return;
+  }
+
+  if ($tr->project_id && !$self->params->{override_project_id} && $tr->project_id != ($matching_order->globalproject_id || 0)) {
+    $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project of order does not match project of time recording');
+    return;
+  }
+
+  return $matching_order;
+}
+
+sub log_error {
+  my ($self, $msg) = @_;
+
+  my $dbg = 0;
+
+  push @{ $self->{job_errors} }, $msg;
+  $::lxdebug->message(LXDebug->WARN(), 'ConvertTimeRecordings: ' . $msg) if $dbg;
+}
+
+1;
+
+__END__
+
+=pod
+
+=encoding utf8
+
+=head1 NAME
+
+SL::BackgroundJob::ConvertTimeRecordings - Convert time recording
+entries into delivery orders
+
+=head1 SYNOPSIS
+
+Get all time recording entries for the given period and customer numbers
+and create delivery ordes out of that (using
+C<SL::DB::DeliveryOrder-E<gt>new_from_time_recordings>).
+
+=head1 CONFIGURATION
+
+Some data can be provided to configure this backgroung job.
+If there is user data and it cannot be validated the background job
+fails.
+
+Example:
+
+  from_date: 01.12.2020
+  to_date: 15.12.2020
+  customernumbers: [1,2,3]
+
+=over 4
+
+=item C<from_date>
+
+The date from which on time recordings should be collected. It defaults
+to the first day of the previous month.
+
+Example (format depends on your settings):
+
+from_date: 01.12.2020
+
+=item C<to_date>
+
+The date till which time recordings should be collected. It defaults
+to the last day of the previous month.
+
+Example (format depends on your settings):
+
+to_date: 15.12.2020
+
+=item C<customernumbers>
+
+An array with the customer numbers for which time recordings should
+be collected. If not given, time recordings for all customers are
+collected.
+
+customernumbers: [c1,22332,334343]
+
+=item C<override_part_id>
+
+The part id of a time based service which should be used to
+book the times instead of the parts which are set in the time
+recordings.
+
+=item C<default_part_id>
+
+The part id of a time based service which should be used to
+book the times if no part is set in the time recording entry.
+
+=item C<rounding>
+
+If set the 0 no rounding of the times will be done otherwise
+the times will be rounded up to the full quarters of an hour,
+ie. 0.25h 0.5h 0.75h 1.25h ...
+Defaults to rounding true (1).
+
+=item C<link_order>
+
+If set the job links the created delivery order with the order
+given in the time recording entry. If there is no order given, then
+it tries to find an order with the current customer and project
+number. It tries to do as much automatic workflow processing as the
+UI.
+Defaults to off. If set to true (1) the job will fail if there
+is no sales order which qualifies as a predecessor.
+Conditions for a predeccesor:
+
+ * Order given in time recording entry OR
+ * Global project_id must match time_recording.project_id OR data.project_id
+ * Customer must match customer in time recording entry
+ * The sales order must have at least one or more time related services
+ * The Project needs to be valid and active
+
+The job doesn't care if the sales order is already delivered or closed.
+If the sales order is overdelivered some organisational stuff needs to be done.
+The sales order may also already be closed, ie the amount is fully billed, but
+the services are not yet fully delivered (simple case: 'Payment in advance').
+
+Hint: take a look or extend the job CloseProjectsBelongingToClosedSalesOrder for
+further automatisation of your organisational needs.
+
+=item C<override_project_id>
+
+Use this project id instead of the project id in the time recordings to find
+a related order. This is only used if C<link_order> is true.
+
+=item C<default_project_id>
+
+Use this project id if no project id is set in the time recording
+entry. This is only used if C<link_order> is true.
+
+=back
+
+=head1 TODO
+
+=over 4
+
+=item * part and project parameters as numbers
+
+Add parameters to give part and project not with their ids, but with their
+numbers. E.g. (default_/override_)part_number,
+(default_/override_)project_number.
+
+
+=back
+
+=head1 AUTHOR
+
+Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>
+
+=cut
index 9c0e58f..6151ec1 100644 (file)
@@ -178,6 +178,8 @@ sub _replace_vars {
 sub _adjust_sellprices_for_period_lengths {
   my (%params) = @_;
 
+  return if $params{config}->periodicity eq 'o';
+
   my $billing_len     = $params{config}->get_billing_period_length;
   my $order_value_len = $params{config}->get_order_value_period_length;
 
index 7d8c362..49bde80 100644 (file)
@@ -303,20 +303,15 @@ First some JavaScript code:
   // In the client generate an AJAX request whose 'success' handler
   // calls "eval_json_result(data)":
   var data = {
-    action: "SomeController/the_action",
+    action: "SomeController/my_personal_action",
     id:     $('#some_input_field').val()
   };
   $.post("controller.pl", data, eval_json_result);
 
-Now some Perl code:
+Now some Controller (perl) code for my personal action:
 
-  # In the controller itself. First, make sure that the "client_js.js"
-  # is loaded. This must be done when the whole side is loaded, so
-  # it's not in the action called by the AJAX request shown above.
-  $::request->layout->use_javascript('client_js.js');
-
-  # Now in that action called via AJAX:
-  sub action_the_action {
+  # my personal action
+  sub action_my_personal_action {
     my ($self) = @_;
 
     # Create a new client-side JS object and do stuff with it!
index bb1e8d6..05043d9 100644 (file)
@@ -604,8 +604,9 @@ sub save_report_single {
       $self->track_progress(progress => $row / @{ $self->data } * 100) if $row % 1000 == 0;
       my $data_row = $self->{data}[$row];
 
+      my $object = $data_row->{object_to_save} || $data_row->{object};
       do_statement($::form, $sth, $query, $report->id,       $_, $row + 1, $data_row->{info_data}{ $info_methods[$_] }) for 0 .. $#info_methods;
-      do_statement($::form, $sth, $query, $report->id, $o1 + $_, $row + 1, $data_row->{object}->${ \ $methods[$_] })    for 0 .. $#methods;
+      do_statement($::form, $sth, $query, $report->id, $o1 + $_, $row + 1, $object->${ \ $methods[$_] })                for 0 .. $#methods;
       do_statement($::form, $sth, $query, $report->id, $o2 + $_, $row + 1, $data_row->{raw_data}{ $raw_methods[$_] })   for 0 .. $#raw_methods;
 
       do_statement($::form, $sth2, $query2, $report->id, $row + 1, 'information', $_) for @{ $data_row->{information} || [] };
@@ -694,8 +695,9 @@ sub save_report_multi {
       my $o1 = $off1->{$row_ident};
       my $o2 = $off2->{$row_ident};
 
+      my $object = $data_row->{object_to_save} || $data_row->{object};
       do_statement($::form, $sth, $query, $report->id,       $_, $row + $n_header_rows, $data_row->{info_data}{ $info_methods->{$row_ident}->[$_] }) for 0 .. $#{ $info_methods->{$row_ident} };
-      do_statement($::form, $sth, $query, $report->id, $o1 + $_, $row + $n_header_rows, $data_row->{object}->${ \ $methods->{$row_ident}->[$_] })    for 0 .. $#{ $methods->{$row_ident} };
+      do_statement($::form, $sth, $query, $report->id, $o1 + $_, $row + $n_header_rows, $object->${ \ $methods->{$row_ident}->[$_] })                for 0 .. $#{ $methods->{$row_ident} };
       do_statement($::form, $sth, $query, $report->id, $o2 + $_, $row + $n_header_rows, $data_row->{raw_data}{ $raw_methods->{$row_ident}->[$_] })   for 0 .. $#{ $raw_methods->{$row_ident} };
 
       do_statement($::form, $sth2, $query2, $report->id, $row + $n_header_rows, 'information', $_) for @{ $data_row->{information} || [] };
index 8b5b5e7..7706373 100644 (file)
@@ -41,7 +41,7 @@ sub check_objects {
   $self->controller->track_progress(phase => 'building data', progress => 0);
   my $update_policy  = $self->controller->profile->get('update_policy') || 'skip';
 
-  my $i;
+  my $i = 0;
   my $num_data = scalar @{ $self->controller->data };
   foreach my $entry (@{ $self->controller->data }) {
     $self->controller->track_progress(progress => $i/$num_data * 100) if $i % 100 == 0;
index 663be5e..fe8af33 100644 (file)
@@ -288,6 +288,9 @@ sub check_vc {
 sub handle_cvars {
   my ($self, $entry) = @_;
 
+  my $object = $entry->{object_to_save} || $entry->{object};
+  return unless $object->can('cvars_by_config');
+
   my %type_to_column = ( text      => 'text_value',
                          textfield => 'text_value',
                          select    => 'text_value',
@@ -296,6 +299,7 @@ sub handle_cvars {
                          number    => 'number_value_as_number',
                          bool      => 'bool_value' );
 
+  # autovivify all cvars (cvars_by_config will do that for us)
   my @cvars;
   my %changed_cvars;
   foreach my $config (@{ $self->all_cvar_configs }) {
@@ -310,16 +314,13 @@ sub handle_cvars {
   }
 
   # merge existing with new cvars. swap every existing with the imported one, push the rest
-  if (@cvars) {
-    my @orig_cvars = ($entry->{object_to_save} || $entry->{object})->custom_variables;
-    for (@orig_cvars) {
-      $_ = $changed_cvars{ $_->config->name } if $changed_cvars{ $_->config->name };
-      delete $changed_cvars{ $_->config->name };
-    }
-    push @orig_cvars, values %changed_cvars;
-
-    $entry->{object}->custom_variables(\@orig_cvars);
+  my @orig_cvars = @{ $object->cvars_by_config };
+  for (@orig_cvars) {
+    $_ = $changed_cvars{ $_->config->name } if $changed_cvars{ $_->config->name };
+    delete $changed_cvars{ $_->config->name };
   }
+  push @orig_cvars, values %changed_cvars;
+  $object->custom_variables(\@orig_cvars);
 }
 
 sub init_profile {
index 5168ae2..2632722 100644 (file)
@@ -170,6 +170,7 @@ sub handle_cvars {
   my ($self, $entry, %params) = @_;
 
   return if @{ $entry->{errors} };
+  return unless $entry->{object}->can('cvars_by_config');
 
   my %type_to_column = ( text      => 'text_value',
                          textfield => 'text_value',
@@ -183,7 +184,7 @@ sub handle_cvars {
 
   # autovivify all cvars (cvars_by_config will do that for us)
   my @cvars;
-  @cvars = @{ $entry->{object}->cvars_by_config } if $entry->{object}->can('cvars_by_config');
+  @cvars = @{ $entry->{object}->cvars_by_config };
 
   foreach my $config (@{ $self->cvar_configs_by->{row_ident}->{$entry->{raw_data}->{datatype}} }) {
     next unless exists $entry->{raw_data}->{ "cvar_" . $config->name };
index 0f2b529..2f8b15c 100644 (file)
@@ -68,7 +68,7 @@ sub check_objects {
   my %vcs_by_number = map { ( $_->$numbercolumn => $_ ) } @{ $self->existing_objects };
   my $methods       = $self->controller->headers->{methods};
 
-  my $i;
+  my $i = 0;
   my $num_data = scalar @{ $self->controller->data };
   foreach my $entry (@{ $self->controller->data }) {
     $self->controller->track_progress(progress => $i/$num_data * 100) if $i % 100 == 0;
@@ -82,7 +82,6 @@ sub check_objects {
     $self->check_taxzone($entry,  take_default => 1);
     $self->check_currency($entry, take_default => 1);
     $self->check_salesman($entry);
-    $self->handle_cvars($entry);
 
     next if @{ $entry->{errors} };
 
@@ -92,7 +91,7 @@ sub check_objects {
     push @{ $entry->{information} }, $::locale->text('Illegal characters have been removed from the following fields: #1', join(', ', @cleaned_fields))
       if @cleaned_fields;
 
-    my $existing_vc = $vcs_by_number{ $object->$numbercolumn };
+    my $existing_vc = $object->$numbercolumn ? $vcs_by_number{ $object->$numbercolumn } : undef;
     if (!$existing_vc) {
       $vcs_by_number{ $object->$numbercolumn } = $object if $object->$numbercolumn;
 
@@ -105,14 +104,14 @@ sub check_objects {
 
       $existing_vc->$_( $entry->{object}->$_ ) for @{ $methods }, keys %{ $self->clone_methods };
 
-      $self->handle_cvars($entry);
-      $existing_vc->custom_variables($entry->{object}->custom_variables);
-
       push @{ $entry->{information} }, $::locale->text('Updating existing entry in database');
 
     } else {
       $object->$numbercolumn('####');
     }
+
+    $self->handle_cvars($entry);
+
   } continue {
     $i++;
   }
@@ -241,21 +240,10 @@ 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 } ];
+  my $with_number    = [ grep { ($_->{object}->$numbercolumn || '') ne '####' } @{ $self->controller->data } ];
+  my $without_number = [ grep { ($_->{object}->$numbercolumn || '') eq '####' } @{ $self->controller->data } ];
 
-  foreach my $entry (@{$with_number}, @{$without_number}) {
-    my $object = $entry->{object};
-
-    my $number = SL::TransNumber->new(type        => $self->table(),
-                                      number      => $object->$numbercolumn(),
-                                      business_id => $object->business_id(),
-                                      save        => 1);
-
-    if ( $object->$numbercolumn eq '####' || !$number->is_unique() ) {
-      $object->$numbercolumn($number->create_unique());
-    }
-  }
+  $_->{object}->$numbercolumn('') for @{ $without_number };
 
   $self->SUPER::save_objects(data => $with_number);
   $self->SUPER::save_objects(data => $without_number);
index 238abf3..c724fb8 100644 (file)
@@ -79,7 +79,7 @@ sub check_objects {
 
   $self->controller->track_progress(phase => 'building data', progress => 0);
 
-  my $i;
+  my $i = 0;
   my $num_data = scalar @{ $self->controller->data };
   foreach my $entry (@{ $self->controller->data }) {
     $self->controller->track_progress(progress => $i/$num_data * 100) if $i % 100 == 0;
index ecd212c..9afaf63 100644 (file)
@@ -175,7 +175,7 @@ sub check_objects {
     $self->handle_prices($entry) if $self->settings->{sellprice_adjustment};
     $self->handle_shoparticle($entry);
     $self->handle_translations($entry);
-    $self->handle_cvars($entry);
+    $self->handle_cvars($entry) unless $entry->{dont_handle_cvars};
     $self->handle_makemodel($entry);
     $self->set_various_fields($entry);
   } continue {
@@ -320,7 +320,8 @@ sub check_existing {
       $entry->{part}->prices(grep { $_ } map { $prices_by_pricegroup_id{$_->id} } @{ $self->all_pricegroups });
 
       push @{ $entry->{information} }, $::locale->text('Updating prices of existing entry in database');
-      $entry->{object_to_save} = $entry->{part};
+      $entry->{object_to_save}    = $entry->{part};
+      $entry->{dont_handle_cvars} = 1;
     } elsif ( $self->settings->{article_number_policy} eq 'update_parts' || $self->settings->{article_number_policy} eq 'update_parts_sn') {
 
       # Update parts table
@@ -355,24 +356,6 @@ sub check_existing {
       }
       $entry->{part}->translations(\@translations) if @translations;
 
-      # Update cvars
-      my %type_to_column = ( text      => 'text_value',
-                             textfield => 'text_value',
-                             select    => 'text_value',
-                             date      => 'timestamp_value_as_date',
-                             timestamp => 'timestamp_value_as_date',
-                             number    => 'number_value_as_number',
-                             bool      => 'bool_value' );
-      my @cvars;
-      push @cvars, $entry->{part}->custom_variables;
-      foreach my $config (@{ $self->all_cvar_configs }) {
-        next unless exists $raw->{ "cvar_" . $config->name };
-        my $value  = $raw->{ "cvar_" . $config->name };
-        my $column = $type_to_column{ $config->type } || die "Program logic error: unknown custom variable storage type";
-        push @cvars, SL::DB::CustomVariable->new(config_id => $config->id, $column => $value, sub_module => '');
-      }
-      $entry->{part}->custom_variables(\@cvars) if @cvars;
-
       # save Part Update
       push @{ $entry->{information} }, $::locale->text('Updating data of existing entry in database');
 
index 4b846dc..4fea748 100644 (file)
@@ -32,7 +32,7 @@ sub check_objects {
 
   $self->controller->track_progress(phase => 'building data', progress => 0);
 
-  my $i;
+  my $i = 0;
   my $num_data = scalar @{ $self->controller->data };
   foreach my $entry (@{ $self->controller->data }) {
     $self->controller->track_progress(progress => $i/$num_data * 100) if $i % 100 == 0;
index b4d9e00..1ca25ad 100644 (file)
@@ -24,7 +24,7 @@ sub check_objects {
 
   $self->controller->track_progress(phase => 'building data', progress => 0);
 
-  my $i;
+  my $i = 0;
   my $num_data = scalar @{ $self->controller->data };
   foreach my $entry (@{ $self->controller->data }) {
     $self->controller->track_progress(progress => $i/$num_data * 100) if $i % 100 == 0;
index fdfded2..9005d79 100644 (file)
@@ -66,6 +66,7 @@ my %file_types = (
   'purchase_invoice'        => { gen => 6, gltype => 'ap', dir =>'PurchaseInvoice',      model => 'PurchaseInvoice',right => 'import_ap'  },
   'vendor'                  => { gen => 0, gltype => '',   dir =>'Vendor',               model => 'Vendor',         right => 'xx'         },
   'customer'                => { gen => 1, gltype => '',   dir =>'Customer',             model => 'Customer',       right => 'xx'         },
+  'project'                 => { gen => 0, gltype => '',   dir =>'Project',              model => 'Project',        right => 'xx'         },
   'part'                    => { gen => 0, gltype => '',   dir =>'Part',                 model => 'Part',           right => 'xx'         },
   'gl_transaction'          => { gen => 6, gltype => 'gl', dir =>'GeneralLedger',        model => 'GLTransaction',  right => 'import_ap'  },
   'draft'                   => { gen => 0, gltype => '',   dir =>'Draft',                model => 'Draft',          right => 'xx'         },
@@ -317,10 +318,16 @@ sub action_download {
 sub action_ajax_get_thumbnail {
   my ($self) = @_;
 
-  my $file      = SL::File->get(id => $::form->{file_id});
+  my $id      = $::form->{file_id};
+  my $version = $::form->{file_version};
+  my $file    = SL::File->get(id => $id);
+
+  $file->version($version) if $version;
+
   my $thumbnail = _create_thumbnail($file, $::form->{size});
 
-  my $overlay_selector = '#enlarged_thumb_' . $::form->{file_id};
+  my $overlay_selector  = '#enlarged_thumb_' . $id;
+  $overlay_selector    .= '_' . $version            if $version;
   $self->js
     ->attr($overlay_selector, 'src', 'data:' . $thumbnail->{thumbnail_img_content_type} . ';base64,' . MIME::Base64::encode_base64($thumbnail->{thumbnail_img_content}))
     ->data($overlay_selector, 'is-overlay-loaded', '1')
index 15df158..5a7d58e 100644 (file)
@@ -338,6 +338,49 @@ sub action_print {
     ->run('kivi.ActionBar.setEnabled', '#save_and_email_action')
     ->render;
 }
+sub action_preview_pdf {
+  my ($self) = @_;
+
+  my $errors = $self->save();
+  if (scalar @{ $errors }) {
+    $self->js->flash('error', $_) foreach @{ $errors };
+    return $self->js->render();
+  }
+
+  $self->js_reset_order_and_item_ids_after_save;
+
+  my $format      = 'pdf';
+  my $media       = 'screen';
+  my $formname    = $self->type;
+
+  # only pdf
+  # create a form for generate_attachment_filename
+  my $form   = Form->new;
+  $form->{$self->nr_key()}  = $self->order->number;
+  $form->{type}             = $self->type;
+  $form->{format}           = $format;
+  $form->{formname}         = $formname;
+  $form->{language}         = '_' . $self->order->language->template_code if $self->order->language;
+  my $pdf_filename          = $form->generate_attachment_filename();
+
+  my $pdf;
+  my @errors = generate_pdf($self->order, \$pdf, { format     => $format,
+                                                   formname   => $formname,
+                                                   language   => $self->order->language,
+                                                 });
+  if (scalar @errors) {
+    return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
+  }
+  $self->save_history('PREVIEWED');
+  $self->js->flash('info', t8('The PDF has been previewed'));
+  # screen/download
+  $self->send_file(
+    \$pdf,
+    type         => SL::MIME->mime_type_from_ext($pdf_filename),
+    name         => $pdf_filename,
+    js_no_render => 0,
+  );
+}
 
 # open the email dialog
 sub action_save_and_show_email_dialog {
@@ -1847,14 +1890,24 @@ sub setup_edit_action_bar {
         action => [
           t8('Export'),
         ],
+        action => [
+          t8('Save and preview PDF'),
+           call => [ 'kivi.Order.save', 'preview_pdf', $::instance_conf->get_order_warn_duplicate_parts,
+                                                       $::instance_conf->get_order_warn_no_deliverydate,
+                                                                                                         ],
+        ],
         action => [
           t8('Save and print'),
-          call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts ],
+          call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts,
+                                                     $::instance_conf->get_order_warn_no_deliverydate,
+                                                                                                      ],
         ],
         action => [
           t8('Save and E-mail'),
           id   => 'save_and_email_action',
-          call => [ 'kivi.Order.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts ],
+          call => [ 'kivi.Order.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts,
+                                                                     $::instance_conf->get_order_warn_no_deliverydate,
+                  ],
           disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
         ],
         action => [
@@ -1965,6 +2018,7 @@ sub get_files_for_email_dialog {
     $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id,              object_type => $self->order->type, file_type => 'document') ];
     $files{files}    = [ SL::File->get_all(         object_id => $self->order->id,              object_type => $self->order->type, file_type => 'attachment') ];
     $files{vc_files} = [ SL::File->get_all(         object_id => $self->order->{$self->cv}->id, object_type => $self->cv,          file_type => 'attachment') ];
+    $files{project_files} = [ SL::File->get_all(    object_id => $self->order->globalproject_id, object_type => 'project',         file_type => 'attachment') ];
   }
 
   my @parts =
index cfc94ee..0b476ea 100644 (file)
@@ -12,6 +12,7 @@ use SL::Controller::Helper::GetModels;
 use SL::Locale::String qw(t8);
 use SL::JSON;
 use List::Util qw(sum);
+use List::UtilsBy qw(extract_by);
 use SL::Helper::Flash;
 use Data::Dumper;
 use DateTime;
@@ -230,8 +231,11 @@ sub render_form {
 
   $params{CUSTOM_VARIABLES}  = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
 
-  CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
-    if (scalar @{ $params{CUSTOM_VARIABLES} });
+  if (scalar @{ $params{CUSTOM_VARIABLES} }) {
+    CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id);
+    $params{CUSTOM_VARIABLES_FIRST_TAB}       = [];
+    @{ $params{CUSTOM_VARIABLES_FIRST_TAB} }  = extract_by { $_->{first_tab} == 1 } @{ $params{CUSTOM_VARIABLES} };
+  }
 
   my %title_hash = ( part       => t8('Edit Part'),
                      assembly   => t8('Edit Assembly'),
index 72f9bd4..94862e1 100644 (file)
@@ -120,10 +120,14 @@ sub action_ajax_autocomplete {
 
   $::form->{sort_by} = 'customer_and_description';
 
+  my $description_style = ($::form->{description_style} =~ m{both|number|description|full})
+                        ? $::form->{description_style}
+                        : 'full';
+
   my @hashes = map {
    +{
-     value         => $_->full_description(style => 'full'),
-     label         => $_->full_description(style => 'full'),
+     value         => $_->full_description(style => $description_style),
+     label         => $_->full_description(style => $description_style),
      id            => $_->id,
      projectnumber => $_->projectnumber,
      description   => $_->description,
@@ -244,6 +248,7 @@ sub display_form {
 
   CVar->render_inputs(variables => $params{CUSTOM_VARIABLES}) if @{ $params{CUSTOM_VARIABLES} };
 
+  $::request->layout->use_javascript('kivi.File.js');
   $self->setup_edit_action_bar(callback => $params{callback});
 
   $self->render('project/form', %params);
index 715f883..2da4a41 100644 (file)
@@ -29,33 +29,45 @@ sub action_get_orders {
   my ( $self ) = @_;
   my $orders_fetched;
   my $new_orders;
-  my %new_order;
-  my $active_shops = SL::DB::Manager::Shop->get_all(query => [ obsolete => 0 ]);
-  foreach my $shop_config ( @{ $active_shops } ) {
-    my $shop = SL::Shop->new( config => $shop_config );
-    my $connect = $shop->check_connectivity;
-
-    if( !$connect->{success} ){
-      %new_order  = (
-        number_of_orders => $connect->{data}->{version},
-        shop_id          => $shop->config->description,
-        error            => 1,
-     );
-     $new_orders = \%new_order;
-    }else{
+
+  my $type = $::form->{type};
+  if ( $type eq "get_next" ) {
+    my $active_shops = SL::DB::Manager::Shop->get_all(query => [ obsolete => 0 ]);
+    foreach my $shop_config ( @{ $active_shops } ) {
+      my $shop = SL::Shop->new( config => $shop_config );
+
       $new_orders = $shop->connector->get_new_orders;
+      push @{ $orders_fetched }, $new_orders ;
+    }
+
+  } elsif ( $type eq "get_one" ) {
+    my $shop_id = $::form->{shop_id};
+    my $shop_ordernumber = $::form->{shop_ordernumber};
+
+    if ( $shop_id && $shop_ordernumber ){
+      my $shop_config = SL::DB::Manager::Shop->get_first(query => [ id => $shop_id, obsolete => 0 ]);
+      my $shop = SL::Shop->new( config => $shop_config );
+      unless ( SL::DB::Manager::ShopOrder->get_all_count( query => [ shop_ordernumber => $shop_ordernumber, shop_id => $shop_id, obsolete => 'f' ] )) {
+        my $connect = $shop->check_connectivity;
+        $new_orders = $shop->connector->get_one_order($shop_ordernumber);
+        push @{ $orders_fetched }, $new_orders ;
+      } else {
+        flash_later('error', t8('Shoporder "#2" From Shop "#1" is already fetched', $shop->config->description, $shop_ordernumber));
+      }
+    } else {
+        flash_later('error', t8('Shop or ordernumber not selected.'));
     }
-    push @{ $orders_fetched }, $new_orders ;
   }
 
   foreach my $shop_fetched(@{ $orders_fetched }) {
     if($shop_fetched->{error}){
-      flash_later('error', t8('From shop "#1" :  #2 ', $shop_fetched->{shop_id}, $shop_fetched->{number_of_orders},));
+      flash_later('error', t8('From shop "#1" :  #2 ', $shop_fetched->{shop_description}, $shop_fetched->{message},));
     }else{
-      flash_later('info', t8('From shop #1 :  #2 shoporders have been fetched.', $shop_fetched->{shop_id}, $shop_fetched->{number_of_orders},));
+      flash_later('info', t8('From shop #1 :  #2 shoporders have been fetched.', $shop_fetched->{description}, $shop_fetched->{number_of_orders},));
     }
   }
-  $self->redirect_to(controller => "ShopOrder", action => 'list');
+
+  $self->redirect_to(controller => "ShopOrder", action => 'list', filter => { 'transferred:eq_ignore_empty' => 0, obsolete => 0 });
 }
 
 sub action_list {
@@ -70,13 +82,7 @@ sub action_list {
                                                     );
 
   foreach my $shop_order(@{ $shop_orders }){
-
-    my $open_invoices = SL::DB::Manager::Invoice->get_all_count(
-      query => [customer_id => $shop_order->{kivi_customer_id},
-              paid => {lt_sql => 'amount'},
-      ],
-    );
-    $shop_order->{open_invoices} = $open_invoices;
+    $shop_order->{open_invoices} = $shop_order->check_for_open_invoices;
   }
   $self->_setup_list_action_bar;
   $self->render('shop_order/list',
@@ -102,6 +108,14 @@ sub action_show {
 
 }
 
+sub action_customer_assign_to_shoporder {
+  my ($self) = @_;
+
+  $self->shop_order->assign_attributes( kivi_customer => $::form->{customer} );
+  $self->shop_order->save;
+  $self->redirect_to(controller => "ShopOrder", action => 'show', id => $self->shop_order->id);
+}
+
 sub action_delete_order {
   my ( $self ) = @_;
 
@@ -115,7 +129,7 @@ sub action_undelete_order {
 
   $self->shop_order->obsolete(0);
   $self->shop_order->save;
-  $self->redirect_to(controller => "ShopOrder", action => 'show', id => $self->shop_order->id);
+  $self->redirect_to(controller => "ShopOrder", action => 'list', filter => { 'transferred:eq_ignore_empty' => 0, obsolete => 0 });
 }
 
 sub action_transfer {
@@ -172,9 +186,10 @@ sub action_mass_transfer {
   )->set_data(
      shop_order_record_ids       => [ @shop_orders ],
      num_order_created           => 0,
+     num_order_failed            => 0,
      num_delivery_order_created  => 0,
      status                      => SL::BackgroundJob::ShopOrderMassTransfer->WAITING_FOR_EXECUTION(),
-     conversion_errors         => [ ],
+     conversion_errors         => [],
    )->update_next_run_at;
 
    SL::System::TaskServer->new->wake_up;
@@ -278,10 +293,17 @@ sub _setup_list_action_bar {
           t8('Search'),
           submit    => [ '#shoporders', { action => "ShopOrder/list" } ],
         ],
-         link => [
-          t8('Shoporders'),
-          link => [ $self->url_for(action => 'get_orders') ],
-          tooltip => t8('New shop orders'),
+        combobox => [
+          link => [
+            t8('Shoporders'),
+            call    => [ 'kivi.ShopOrder.get_orders_next' ],
+            tooltip => t8('New shop orders'),
+          ],
+          action => [
+            t8('Get one order'),
+            call    => [ 'kivi.ShopOrder.get_one_order_setup', id => "get_one" ],
+            tooltip => t8('Get one order by shopordernumber'),
+          ],
         ],
         'separator',
         action => [
index f8dd4bc..463d74f 100644 (file)
@@ -268,6 +268,20 @@ my %supported_types = (
     ],
   },
 
+  time_recording_article => {
+    # Make locales.pl happy: $self->render("simple_system_setting/_time_recording_article_form")
+    class  => 'TimeRecordingArticle',
+    auth   => 'config',
+    titles => {
+      list => t8('Time Recording Articles'),
+      add  => t8('Add time recording article'),
+      edit => t8('Edit time recording article'),
+    },
+    list_attributes => [
+      { title => t8('Article'), formatter => sub { $_[0]->part->displayable_name } },
+    ],
+  },
+
 );
 
 my @default_list_attributes = (
diff --git a/SL/Controller/TimeRecording.pm b/SL/Controller/TimeRecording.pm
new file mode 100644 (file)
index 0000000..f87c4d2
--- /dev/null
@@ -0,0 +1,390 @@
+package SL::Controller::TimeRecording;
+
+use strict;
+use parent qw(SL::Controller::Base);
+
+use DateTime;
+use English qw(-no_match_vars);
+use POSIX qw(strftime);
+
+use SL::Controller::Helper::GetModels;
+use SL::Controller::Helper::ReportGenerator;
+use SL::DB::Customer;
+use SL::DB::Employee;
+use SL::DB::Order;
+use SL::DB::Part;
+use SL::DB::Project;
+use SL::DB::TimeRecording;
+use SL::DB::TimeRecordingArticle;
+use SL::Helper::Flash qw(flash);
+use SL::Helper::Number qw(_round_number _parse_number);
+use SL::Helper::UserPreferences::TimeRecording;
+use SL::Locale::String qw(t8);
+use SL::ReportGenerator;
+
+use Rose::Object::MakeMethods::Generic
+(
+# scalar                  => [ qw() ],
+ 'scalar --get_set_init' => [ qw(time_recording models all_employees all_time_recording_articles all_orders can_view_all can_edit_all use_duration) ],
+);
+
+
+# safety
+__PACKAGE__->run_before('check_auth');
+__PACKAGE__->run_before('check_auth_edit', only => [ qw(edit save delete) ]);
+
+my %sort_columns = (
+  date         => t8('Date'),
+  start_time   => t8('Start'),
+  end_time     => t8('End'),
+  order        => t8('Sales Order'),
+  customer     => t8('Customer'),
+  part         => t8('Article'),
+  project      => t8('Project'),
+  description  => t8('Description'),
+  staff_member => t8('Mitarbeiter'),
+  duration     => t8('Duration'),
+  booked       => t8('Booked'),
+);
+
+#
+# actions
+#
+
+sub action_list {
+  my ($self, %params) = @_;
+
+  $::form->{filter} //=  {
+    staff_member_id => SL::DB::Manager::Employee->current->id,
+    "date:date::ge" => DateTime->today_local->add(weeks => -2)->to_kivitendo,
+  };
+
+  $self->setup_list_action_bar;
+  $self->make_filter_summary;
+  $self->prepare_report;
+
+  $self->report_generator_list_objects(report => $self->{report}, objects => $self->models->get);
+}
+
+sub action_edit {
+  my ($self) = @_;
+
+  $::request->{layout}->use_javascript("${_}.js") for qw(kivi.TimeRecording ckeditor/ckeditor ckeditor/adapters/jquery kivi.Validator);
+
+  if ($self->use_duration) {
+    flash('warning', t8('This entry is using start and end time. This information will be overwritten on saving.')) if !$self->time_recording->is_duration_used;
+  } else {
+    flash('warning', t8('This entry is using date and duration. This information will be overwritten on saving.'))  if $self->time_recording->is_duration_used;
+  }
+
+  if ($self->time_recording->start_time) {
+    $self->{start_date} = $self->time_recording->start_time->to_kivitendo;
+    $self->{start_time} = $self->time_recording->start_time->to_kivitendo_time;
+  }
+  if ($self->time_recording->end_time) {
+    $self->{end_date}   = $self->time_recording->end_time->to_kivitendo;
+    $self->{end_time}   = $self->time_recording->end_time->to_kivitendo_time;
+  }
+
+  $self->setup_edit_action_bar;
+
+  $self->render('time_recording/form',
+                title  => t8('Time Recording'),
+  );
+}
+
+sub action_save {
+  my ($self) = @_;
+
+  if ($self->use_duration) {
+    $self->time_recording->start_time(undef);
+    $self->time_recording->end_time(undef);
+  }
+
+  my @errors = $self->time_recording->validate;
+  if (@errors) {
+    $::form->error(t8('Saving the time recording entry failed: #1', join '<br>', @errors));
+    return;
+  }
+
+  if ( !eval { $self->time_recording->save; 1; } ) {
+    $::form->error(t8('Saving the time recording entry failed: #1', $EVAL_ERROR));
+    return;
+  }
+
+  $self->redirect_to(safe_callback());
+}
+
+sub action_delete {
+  my ($self) = @_;
+
+  $self->time_recording->delete;
+
+  $self->redirect_to(safe_callback());
+}
+
+sub action_ajaj_get_order_info {
+
+  my $order = SL::DB::Order->new(id => $::form->{id})->load;
+  my $data  = { customer => { id    => $order->customer_id,
+                              value => $order->customer->displayable_name,
+                              type  => 'customer'
+                },
+                project => { id     =>  $order->globalproject_id,
+                             value  => ($order->globalproject_id ? $order->globalproject->displayable_name : undef),
+                },
+  };
+
+  $_[0]->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
+}
+
+sub action_ajaj_get_project_info {
+
+  my $project = SL::DB::Project->new(id => $::form->{id})->load;
+
+  my $data;
+  if ($project->customer_id) {
+    $data = { customer => { id    => $project->customer_id,
+                            value => $project->customer->displayable_name,
+                            type  => 'customer'
+                          },
+    };
+  }
+
+  $_[0]->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
+}
+
+sub init_time_recording {
+  my ($self) = @_;
+
+  my $is_new         = !$::form->{id};
+  my $time_recording = !$is_new            ? SL::DB::TimeRecording->new(id => $::form->{id})->load
+                     : $self->use_duration ? SL::DB::TimeRecording->new(date => DateTime->today_local)
+                     :                       SL::DB::TimeRecording->new(start_time => DateTime->now_local);
+
+  my %attributes = %{ $::form->{time_recording} || {} };
+
+  if ($self->use_duration) {
+    if (exists $::form->{duration_h} || exists $::form->{duration_m}) {
+      $attributes{duration} = _round_number(_parse_number($::form->{duration_h}) * 60 + _parse_number($::form->{duration_m}), 0);
+    }
+
+  } else {
+    foreach my $type (qw(start end)) {
+      if ($::form->{$type . '_date'}) {
+        my $date = DateTime->from_kivitendo($::form->{$type . '_date'});
+        $attributes{$type . '_time'} = $date->clone;
+        if ($::form->{$type . '_time'}) {
+          my ($hour, $min) = split ':', $::form->{$type . '_time'};
+          $attributes{$type . '_time'}->set_hour($hour)  if $hour;
+          $attributes{$type . '_time'}->set_minute($min) if $min;
+        }
+      }
+    }
+  }
+
+  # do not overwrite staff member if you do not have the right
+  delete $attributes{staff_member_id}                                     if !$_[0]->can_edit_all;
+  $attributes{staff_member_id} ||= SL::DB::Manager::Employee->current->id if $is_new;
+
+  $attributes{employee_id}       = SL::DB::Manager::Employee->current->id;
+
+  $time_recording->assign_attributes(%attributes);
+
+  return $time_recording;
+}
+
+sub init_can_view_all {
+  $::auth->assert('time_recording_show_all', 1) || $::auth->assert('time_recording_edit_all', 1)
+}
+
+sub init_can_edit_all {
+  $::auth->assert('time_recording_edit_all', 1)
+}
+
+sub init_models {
+  my ($self) = @_;
+
+  my @where;
+  push @where, (staff_member_id => SL::DB::Manager::Employee->current->id) if !$self->can_view_all;
+
+  SL::Controller::Helper::GetModels->new(
+    controller     => $_[0],
+    sorted         => \%sort_columns,
+    disable_plugin => 'paginated',
+    query          => \@where,
+    with_objects   => [ 'customer', 'part', 'project', 'staff_member', 'employee', 'order' ],
+  );
+}
+
+sub init_all_employees {
+  SL::DB::Manager::Employee->get_all_sorted(query => [ deleted => 0 ]);
+}
+
+sub init_all_time_recording_articles {
+  my $selectable_parts = SL::DB::Manager::TimeRecordingArticle->get_all_sorted(
+    query        => [or => [ 'part.obsolete' => 0, 'part.obsolete' => undef ]],
+    with_objects => ['part']);
+
+  my $res              = [ map { {id => $_->part_id, description => $_->part->displayable_name} } @$selectable_parts];
+  my $curr_id          = $_[0]->time_recording->part_id;
+
+  if ($curr_id && !grep { $curr_id == $_->{id} } @$res) {
+    unshift @$res, {id => $curr_id, description => $_[0]->time_recording->part->displayable_name};
+  }
+
+  return $res;
+}
+
+sub init_all_orders {
+  my $orders = SL::DB::Manager::Order->get_all(query => [or             => [ closed => 0, closed => undef ],
+                                                         '!customer_id' => undef]);
+  return [ map { [$_->id, sprintf("%s %s", $_->number, $_->customervendor->name) ] } sort { $a->number <=> $b->number } @{$orders||[]} ];
+}
+
+sub init_use_duration {
+  return SL::Helper::UserPreferences::TimeRecording->new()->get_use_duration();
+}
+
+sub check_auth {
+  $::auth->assert('time_recording');
+}
+
+sub check_auth_edit {
+  my ($self) = @_;
+
+  if (!$self->can_edit_all && ($self->time_recording->staff_member_id != SL::DB::Manager::Employee->current->id)) {
+    $::form->error(t8('You do not have permission to access this entry.'));
+  }
+}
+
+sub prepare_report {
+  my ($self) = @_;
+
+  my $report      = SL::ReportGenerator->new(\%::myconfig, $::form);
+  $self->{report} = $report;
+
+  my @columns  = qw(date start_time end_time order customer project part description staff_member duration booked);
+
+  my %column_defs = (
+    date         => { text => t8('Date'),         sub => sub { $_[0]->date_as_date },
+                      obj_link => sub { $self->url_for(action => 'edit', 'id' => $_[0]->id, callback => $self->models->get_callback) }  },
+    start_time   => { text => t8('Start'),        sub => sub { $_[0]->start_time_as_timestamp },
+                      obj_link => sub { $self->url_for(action => 'edit', 'id' => $_[0]->id, callback => $self->models->get_callback) }  },
+    end_time     => { text => t8('End'),          sub => sub { $_[0]->end_time_as_timestamp },
+                      obj_link => sub { $self->url_for(action => 'edit', 'id' => $_[0]->id, callback => $self->models->get_callback) }  },
+    order        => { text => t8('Sales Order'),  sub => sub { $_[0]->order && $_[0]->order->number } },
+    customer     => { text => t8('Customer'),     sub => sub { $_[0]->customer->displayable_name } },
+    part         => { text => t8('Article'),      sub => sub { $_[0]->part && $_[0]->part->displayable_name } },
+    project      => { text => t8('Project'),      sub => sub { $_[0]->project && $_[0]->project->full_description(sytle => 'both') } },
+    description  => { text => t8('Description'),  sub => sub { $_[0]->description_as_stripped_html },
+                      raw_data => sub { $_[0]->description_as_restricted_html }, # raw_data only used for html(?)
+                      obj_link => sub { $self->url_for(action => 'edit', 'id' => $_[0]->id, callback => $self->models->get_callback) }  },
+    staff_member => { text => t8('Mitarbeiter'),  sub => sub { $_[0]->staff_member->safe_name } },
+    duration     => { text => t8('Duration'),     sub => sub { $_[0]->duration_as_duration_string },
+                      align => 'right'},
+    booked       => { text => t8('Booked'),       sub => sub { $_[0]->booked ? t8('Yes') : t8('No') } },
+  );
+
+  my $title        = t8('Time Recordings');
+  $report->{title} = $title;    # for browser titlebar (title-tag)
+
+  $report->set_options(
+    controller_class      => 'TimeRecording',
+    std_column_visibility => 1,
+    output_format         => 'HTML',
+    title                 => $title, # for heading
+    allow_pdf_export      => 1,
+    allow_csv_export      => 1,
+  );
+
+  $report->set_columns(%column_defs);
+  $report->set_column_order(@columns);
+  $report->set_export_options(qw(list filter));
+  $report->set_options_from_form;
+
+  $self->models->disable_plugin('paginated') if $report->{options}{output_format} =~ /^(pdf|csv)$/i;
+  $self->models->add_additional_url_params(filter => $::form->{filter});
+  $self->models->finalize;
+  $self->models->set_report_generator_sort_options(report => $report, sortable_columns => [keys %sort_columns]);
+
+  $report->set_options(
+    raw_top_info_text    => $self->render('time_recording/report_top',    { output => 0 }),
+    raw_bottom_info_text => $self->render('time_recording/report_bottom', { output => 0 }, models => $self->models),
+    attachment_basename  => t8('time_recordings') . strftime('_%Y%m%d', localtime time),
+  );
+}
+
+sub make_filter_summary {
+  my ($self) = @_;
+
+  my $filter = $::form->{filter} || {};
+  my @filter_strings;
+
+  my $staff_member = $filter->{staff_member_id} ? SL::DB::Employee->new(id => $filter->{staff_member_id})->load->safe_name                         : '';
+  my $project      = $filter->{project_id}      ? SL::DB::Project->new (id => $filter->{project_id})     ->load->full_description(sytle => 'both') : '';
+
+  my @filters = (
+    [ $filter->{"date:date::ge"},                              t8('From Date')       ],
+    [ $filter->{"date:date::le"},                              t8('To Date')         ],
+    [ $filter->{"customer"}->{"name:substr::ilike"},           t8('Customer')        ],
+    [ $filter->{"customer"}->{"customernumber:substr::ilike"}, t8('Customer Number') ],
+    [ $filter->{"order"}->{"ordnumber:substr::ilike"},         t8('Order Number')    ],
+    [ $project,                                                t8('Project')         ],
+    [ $filter->{"description:substr::ilike"},                  t8('Description')     ],
+    [ $staff_member,                                           t8('Mitarbeiter')     ],
+  );
+
+  for (@filters) {
+    push @filter_strings, "$_->[1]: $_->[0]" if $_->[0];
+  }
+
+  $self->{filter_summary} = join ', ', @filter_strings;
+}
+
+sub setup_list_action_bar {
+  my ($self) = @_;
+
+  for my $bar ($::request->layout->get('actionbar')) {
+    $bar->add(
+      action => [
+        t8('Update'),
+        submit    => [ '#filter_form', { action => 'TimeRecording/list' } ],
+        accesskey => 'enter',
+      ],
+      action => [
+        t8('Add'),
+        link => $self->url_for(action => 'edit', callback => $self->models->get_callback),
+      ],
+    );
+  }
+}
+
+sub setup_edit_action_bar {
+  my ($self) = @_;
+
+  for my $bar ($::request->layout->get('actionbar')) {
+    $bar->add(
+      action => [
+        t8('Save'),
+        submit => [ '#form', { action => 'TimeRecording/save' } ],
+        checks => [ 'kivi.validate_form' ],
+      ],
+      action => [
+        t8('Delete'),
+        submit  => [ '#form', { action => 'TimeRecording/delete' } ],
+        only_if => $self->time_recording->id,
+      ],
+      action => [
+        t8('Cancel'),
+        link  => $self->url_for(safe_callback()),
+      ],
+    );
+  }
+}
+
+sub safe_callback {
+  $::form->{callback} || (action => 'list')
+}
+
+1;
index 15eeee0..395a449 100644 (file)
@@ -14,7 +14,14 @@ use SL::DB::Helper::FlattenToForm;
 use SL::DB::Helper::LinkedRecords;
 use SL::DB::Helper::TransNumberGenerator;
 
+use SL::DB::Part;
+use SL::DB::Unit;
+
+use SL::Helper::Number qw(_format_total _round_total);
+
 use List::Util qw(first);
+use List::MoreUtils qw(any pairwise);
+use Math::Round qw(nhimult);
 
 __PACKAGE__->meta->add_relationship(orderitems => { type         => 'one to many',
                                                     class        => 'SL::DB::DeliveryOrderItem',
@@ -172,6 +179,129 @@ sub new_from {
   return $delivery_order;
 }
 
+sub new_from_time_recordings {
+  my ($class, $sources, %params) = @_;
+
+  croak("Unsupported object type in sources")                                      if any { ref($_) ne 'SL::DB::TimeRecording' }            @$sources;
+  croak("Cannot create delivery order from source records of different customers") if any { $_->customer_id != $sources->[0]->customer_id } @$sources;
+
+  # - one item per part (article)
+  # - qty is sum of duration
+  # - description goes to item longdescription
+  #  - ordered and summed by date
+  #  - each description goes to an ordered list
+  #  - (as time recording descriptions are formatted text by now, use stripped text)
+  #  - merge same descriptions
+  #
+
+  my $default_part_id  = $params{default_part_id}     ? $params{default_part_id}
+                       : $params{default_partnumber}  ? SL::DB::Manager::Part->find_by(partnumber => $params{default_partnumber})->id
+                       : undef;
+  my $override_part_id = $params{override_part_id}    ? $params{override_part_id}
+                       : $params{override_partnumber} ? SL::DB::Manager::Part->find_by(partnumber => $params{override_partnumber})->id
+                       : undef;
+
+  # check parts and collect entries
+  my %part_by_part_id;
+  my $entries;
+  foreach my $source (@$sources) {
+    next if !$source->duration;
+
+    my $part_id   = $override_part_id;
+    $part_id    ||= $source->part_id;
+    $part_id    ||= $default_part_id;
+
+    die 'article not found for entry "' . $source->displayable_times . '"' if !$part_id;
+
+    if (!$part_by_part_id{$part_id}) {
+      $part_by_part_id{$part_id} = SL::DB::Part->new(id => $part_id)->load;
+      die 'article unit must be time based for entry "' . $source->displayable_times . '"' if !$part_by_part_id{$part_id}->unit_obj->is_time_based;
+    }
+
+    my $date = $source->date->to_kivitendo;
+    $entries->{$part_id}->{$date}->{duration} += $params{rounding}
+                                               ? nhimult(0.25, ($source->duration_in_hours))
+                                               : _round_total($source->duration_in_hours);
+    # add content if not already in description
+    my $new_description = '' . $source->description_as_stripped_html;
+    $entries->{$part_id}->{$date}->{content} ||= '';
+    $entries->{$part_id}->{$date}->{content}  .= '<li>' . $new_description . '</li>'
+      unless $entries->{$part_id}->{$date}->{content} =~ m/\Q$new_description/;
+
+    $entries->{$part_id}->{$date}->{date_obj}  = $source->start_time || $source->date; # for sorting
+  }
+
+  my @items;
+
+  my $h_unit = SL::DB::Manager::Unit->find_h_unit;
+
+  my @keys = sort { $part_by_part_id{$a}->partnumber cmp $part_by_part_id{$b}->partnumber } keys %$entries;
+  foreach my $key (@keys) {
+    my $qty = 0;
+    my $longdescription = '';
+
+    my @dates = sort { $entries->{$key}->{$a}->{date_obj} <=> $entries->{$key}->{$b}->{date_obj} } keys %{$entries->{$key}};
+    foreach my $date (@dates) {
+      my $entry = $entries->{$key}->{$date};
+
+      $qty             += $entry->{duration};
+      $longdescription .= $date . ' <strong>' . _format_total($entry->{duration}) . ' h</strong>';
+      $longdescription .= '<ul>';
+      $longdescription .= $entry->{content};
+      $longdescription .= '</ul>';
+    }
+
+    my $item = SL::DB::DeliveryOrderItem->new(
+      parts_id        => $part_by_part_id{$key}->id,
+      description     => $part_by_part_id{$key}->description,
+      qty             => $qty,
+      base_qty        => $h_unit->convert_to($qty, $part_by_part_id{$key}->unit_obj),
+      unit_obj        => $h_unit,
+      sellprice       => $part_by_part_id{$key}->sellprice, # Todo: use price rules to get sellprice
+      longdescription => $longdescription,
+    );
+
+    push @items, $item;
+  }
+
+  my $delivery_order;
+
+  if ($params{related_order}) {
+    # collect suitable items in related order
+    my @items_to_use;
+    my @new_attributes;
+    foreach my $item (@items) {
+      my $item_to_use = first {$item->parts_id == $_->parts_id} @{ $params{related_order}->items_sorted };
+
+      die "no suitable item found in related order" if !$item_to_use;
+
+      my %new_attributes;
+      $new_attributes{$_} = $item->$_ for qw(qty base_qty unit_obj longdescription);
+      push @items_to_use,   $item_to_use;
+      push @new_attributes, \%new_attributes;
+    }
+
+    $delivery_order = $class->new_from($params{related_order}, items => \@items_to_use, %params);
+    pairwise { $a->assign_attributes( %$b) } @{$delivery_order->items}, @new_attributes;
+
+  } else {
+    my %args = (
+      is_sales    => 1,
+      delivered   => 0,
+      customer_id => $sources->[0]->customer_id,
+      taxzone_id  => $sources->[0]->customer->taxzone_id,
+      currency_id => $sources->[0]->customer->currency_id,
+      employee_id => SL::DB::Manager::Employee->current->id,
+      salesman_id => SL::DB::Manager::Employee->current->id,
+      items       => \@items,
+    );
+    $delivery_order = $class->new(%args);
+    $delivery_order->assign_attributes(%{ $params{attributes} }) if $params{attributes};
+  }
+
+  return $delivery_order;
+}
+
 sub customervendor {
   $_[0]->is_sales ? $_[0]->customer : $_[0]->vendor;
 }
@@ -301,6 +431,62 @@ order.
 
 =back
 
+=item C<new_from_time_recordings $sources, %params>
+
+Creates a new C<SL::DB::DeliveryOrder> instance from the time recordings
+given as C<$sources>. All time recording entries must belong to the same
+customer. Time recordings are sorted by article and date. For each article
+a new delivery order item is created. If no article is associated with an
+entry, a default article will be used. The article given in the time
+recording entry can be overriden.
+Entries of the same date (for each article) are summed together and form a
+list entry in the long description of the item.
+
+The created delivery order object will be returnd but not saved.
+
+C<$sources> must be an array reference of C<SL::DB::TimeRecording> instances.
+
+C<%params> can include the following options:
+
+=over 2
+
+=item C<attributes>
+
+An optional hash reference. If it exists then it is used to set
+attributes of the newly created delivery order object.
+
+=item C<default_part_id>
+
+An optional part id which is used as default value if no part is set
+in the time recording entry.
+
+=item C<default_partnumber>
+
+Like C<default_part_id> but given as partnumber, not as id.
+
+=item C<override_part_id>
+
+An optional part id which is used instead of a value set in the time
+recording entry.
+
+=item C<override_partnumber>
+
+Like C<overrride_part_id> but given as partnumber, not as id.
+
+=item C<related_order>
+
+An optional C<SL::DB::Order> object. If it exists then it is used to
+generate the delivery order from that via C<new_from>.
+The generated items are created from a suitable item of the related
+order. If no suitable item is found, an exception is thrown.
+
+=item C<rounding>
+
+An optional boolean value. If truish, then the durations of the time entries
+are rounded up to the full quarters of an hour.
+
+=back
+
 =item C<sales_order>
 
 TODO: Describe sales_order
index 006a389..e0ab578 100644 (file)
@@ -134,6 +134,8 @@ use SL::DB::Tax;
 use SL::DB::TaxKey;
 use SL::DB::TaxZone;
 use SL::DB::TaxzoneChart;
+use SL::DB::TimeRecording;
+use SL::DB::TimeRecordingArticle;
 use SL::DB::TodoUserConfig;
 use SL::DB::TransferType;
 use SL::DB::Translation;
index ccd6ca0..f999939 100644 (file)
@@ -97,7 +97,7 @@ sub flatten_to_form {
     _copy($item->part,    $form, '',               "_${idx}", 0,               qw(listprice));
     _copy($item,          $form, '',               "_${idx}", 0,               qw(description project_id ship serialnumber pricegroup_id ordnumber donumber cusordnumber unit
                                                                                   subtotal longdescription price_factor_id marge_price_factor reqdate transdate
-                                                                                  active_price_source active_discount_source));
+                                                                                  active_price_source active_discount_source optional));
     _copy($item,          $form, '',              "_${idx}", $format_noround, qw(qty sellprice fxsellprice));
     _copy($item,          $form, '',              "_${idx}", $format_amounts, qw(marge_total marge_percent lastcost));
     _copy($item,          $form, '',              "_${idx}", $format_percent, qw(discount));
index da931f9..94f7e47 100644 (file)
@@ -214,6 +214,8 @@ my %kivitendo_package_names = (
   taxkeys                        => 'tax_key',
   tax_zones                      => 'tax_zone',
   taxzone_charts                 => 'taxzone_chart',
+  time_recording_articles        => 'time_recording_article',
+  time_recordings                => 'time_recording',
   todo_user_config               => 'todo_user_config',
   transfer_type                  => 'transfer_type',
   translation                    => 'translation',
index 15850d5..bde711a 100644 (file)
@@ -44,7 +44,8 @@ sub calculate_prices_and_taxes {
   # set exchangerate in $data>{exchangerate}
   if ( ref($self) eq 'SL::DB::Order' ) {
     # orders store amount in the order currency
-    $data{exchangerate} = 1;
+    $data{exchangerate}         = 1;
+    $data{allow_optional_items} = 1;
   } else {
     # invoices store amount in the default currency
     _get_exchangerate($self, \%data, %params);
@@ -121,21 +122,21 @@ sub _calculate_item {
   } else {
     $tax_amount = $linetotal * $tax_rate;
   }
-
-  if ($taxkey->tax->chart_id) {
-    $data->{taxes_by_chart_id}->{ $taxkey->tax->chart_id } ||= 0;
-    $data->{taxes_by_chart_id}->{ $taxkey->tax->chart_id }  += $tax_amount;
-    $data->{taxes_by_tax_id}->{ $taxkey->tax_id }          ||= 0;
-    $data->{taxes_by_tax_id}->{ $taxkey->tax_id }           += $tax_amount;
-  } elsif ($tax_amount) {
-    die "tax_amount != 0 but no chart_id for taxkey " . $taxkey->id . " tax " . $taxkey->tax->id;
-  }
-
   my $chart = $part->get_chart(type => $data->{is_sales} ? 'income' : 'expense', taxzone => $self->taxzone_id);
-  $data->{amounts}->{ $chart->id }           ||= { taxkey => $taxkey->taxkey_id, tax_id => $taxkey->tax_id, amount => 0 };
-  $data->{amounts}->{ $chart->id }->{amount}  += $linetotal;
-  $data->{amounts}->{ $chart->id }->{amount}  -= $tax_amount if $self->taxincluded;
+  unless ($data->{allow_optional_items} && $item->optional) {
+    if ($taxkey->tax->chart_id) {
+      $data->{taxes_by_chart_id}->{ $taxkey->tax->chart_id } ||= 0;
+      $data->{taxes_by_chart_id}->{ $taxkey->tax->chart_id }  += $tax_amount;
+      $data->{taxes_by_tax_id}->{ $taxkey->tax_id }          ||= 0;
+      $data->{taxes_by_tax_id}->{ $taxkey->tax_id }           += $tax_amount;
+    } elsif ($tax_amount) {
+      die "tax_amount != 0 but no chart_id for taxkey " . $taxkey->id . " tax " . $taxkey->tax->id;
+    }
 
+    $data->{amounts}->{ $chart->id }           ||= { taxkey => $taxkey->taxkey_id, tax_id => $taxkey->tax_id, amount => 0 };
+    $data->{amounts}->{ $chart->id }->{amount}  += $linetotal;
+    $data->{amounts}->{ $chart->id }->{amount}  -= $tax_amount if $self->taxincluded;
+  }
   my $linetotal_cost = 0;
 
   if (!$linetotal) {
@@ -150,8 +151,10 @@ sub _calculate_item {
     $item->marge_total(  $linetotal_net - $linetotal_cost);
     $item->marge_percent($item->marge_total * 100 / $linetotal_net);
 
-    $self->marge_total(  $self->marge_total + $item->marge_total);
-    $data->{lastcost_total} += $linetotal_cost;
+    unless ($data->{allow_optional_items} && $item->optional) {
+      $self->marge_total(  $self->marge_total + $item->marge_total);
+      $data->{lastcost_total} += $linetotal_cost;
+    }
   }
 
   push @{ $data->{assembly_items} }, [];
diff --git a/SL/DB/Manager/TimeRecording.pm b/SL/DB/Manager/TimeRecording.pm
new file mode 100644 (file)
index 0000000..edb6d1b
--- /dev/null
@@ -0,0 +1,34 @@
+# 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::Manager::TimeRecording;
+
+use strict;
+
+use parent qw(SL::DB::Helper::Manager);
+
+use SL::DB::Helper::Sorted;
+
+sub object_class { 'SL::DB::TimeRecording' }
+
+__PACKAGE__->make_manager_methods;
+
+
+sub _sort_spec {
+  return ( default => [ 'start_time', 1 ],
+           nulls   => {
+             date       => 'FIRST',
+             start_time => 'FIRST',
+             end_time   => 'FIRST',
+           },
+           columns => { SIMPLE     => 'ALL' ,
+                        start_time => [ 'date', 'start_time' ],
+                        end_time   => [ 'date', 'end_time' ],
+                        customer   => [ 'lower(customer.name)', 'date','start_time'],
+                        order      => [ 'order.ordnumber', 'date','start_time'],
+           }
+  );
+}
+
+
+1;
diff --git a/SL/DB/Manager/TimeRecordingArticle.pm b/SL/DB/Manager/TimeRecordingArticle.pm
new file mode 100644 (file)
index 0000000..9048b61
--- /dev/null
@@ -0,0 +1,21 @@
+# 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::Manager::TimeRecordingArticle;
+
+use strict;
+
+use parent qw(SL::DB::Helper::Manager);
+
+use SL::DB::Helper::Sorted;
+
+sub object_class { 'SL::DB::TimeRecordingArticle' }
+
+__PACKAGE__->make_manager_methods;
+
+sub _sort_spec {
+  return ( default => [ 'position', 1 ],
+           columns => { SIMPLE => 'ALL' });
+}
+
+1;
index af6d690..684d229 100644 (file)
@@ -11,6 +11,7 @@ __PACKAGE__->meta->table('custom_variable_configs');
 __PACKAGE__->meta->columns(
   default_value       => { type => 'text' },
   description         => { type => 'text', not_null => 1 },
+  first_tab           => { type => 'boolean', default => 'false', not_null => 1 },
   flags               => { type => 'text' },
   id                  => { type => 'integer', not_null => 1, sequence => 'custom_variable_configs_id' },
   includeable         => { type => 'boolean', not_null => 1 },
index c998dc4..6308292 100644 (file)
@@ -175,6 +175,7 @@ __PACKAGE__->meta->columns(
   transfer_default_use_master_default_bin   => { type => 'boolean', default => 'false' },
   transfer_default_warehouse_for_assembly   => { type => 'boolean', default => 'false' },
   transport_cost_reminder_article_number_id => { type => 'integer' },
+  undo_transfer_interval                    => { type => 'integer', default => 7 },
   vc_greetings_use_textfield                => { type => 'boolean' },
   vendor_ustid_taxnummer_unique             => { type => 'boolean', default => 'false' },
   vendornumber                              => { type => 'text' },
index 16478b1..afa64d8 100644 (file)
@@ -23,6 +23,7 @@ __PACKAGE__->meta->columns(
   marge_price_factor     => { type => 'numeric', default => 1, precision => 15, scale => 5 },
   marge_total            => { type => 'numeric', precision => 15, scale => 5 },
   mtime                  => { type => 'timestamp' },
+  optional               => { type => 'boolean', default => 'false' },
   ordnumber              => { type => 'text' },
   parts_id               => { type => 'integer' },
   position               => { type => 'integer', not_null => 1 },
diff --git a/SL/DB/MetaSetup/TimeRecording.pm b/SL/DB/MetaSetup/TimeRecording.pm
new file mode 100644 (file)
index 0000000..a500131
--- /dev/null
@@ -0,0 +1,67 @@
+# This file has been auto-generated. Do not modify it; it will be overwritten
+# by rose_auto_create_model.pl automatically.
+package SL::DB::TimeRecording;
+
+use strict;
+
+use parent qw(SL::DB::Object);
+
+__PACKAGE__->meta->table('time_recordings');
+
+__PACKAGE__->meta->columns(
+  booked          => { type => 'boolean', default => 'false' },
+  customer_id     => { type => 'integer', not_null => 1 },
+  date            => { type => 'date', not_null => 1 },
+  description     => { type => 'text', not_null => 1 },
+  duration        => { type => 'integer' },
+  employee_id     => { type => 'integer', not_null => 1 },
+  end_time        => { type => 'timestamp' },
+  id              => { type => 'serial', not_null => 1 },
+  itime           => { type => 'timestamp', default => 'now()', not_null => 1 },
+  mtime           => { type => 'timestamp', default => 'now()', not_null => 1 },
+  order_id        => { type => 'integer' },
+  part_id         => { type => 'integer' },
+  payroll         => { type => 'boolean', default => 'false' },
+  project_id      => { type => 'integer' },
+  staff_member_id => { type => 'integer', not_null => 1 },
+  start_time      => { type => 'timestamp' },
+);
+
+__PACKAGE__->meta->primary_key_columns([ 'id' ]);
+
+__PACKAGE__->meta->allow_inline_column_values(1);
+
+__PACKAGE__->meta->foreign_keys(
+  customer => {
+    class       => 'SL::DB::Customer',
+    key_columns => { customer_id => 'id' },
+  },
+
+  employee => {
+    class       => 'SL::DB::Employee',
+    key_columns => { employee_id => 'id' },
+  },
+
+  order => {
+    class       => 'SL::DB::Order',
+    key_columns => { order_id => 'id' },
+  },
+
+  part => {
+    class       => 'SL::DB::Part',
+    key_columns => { part_id => 'id' },
+  },
+
+  project => {
+    class       => 'SL::DB::Project',
+    key_columns => { project_id => 'id' },
+  },
+
+  staff_member => {
+    class       => 'SL::DB::Employee',
+    key_columns => { staff_member_id => 'id' },
+  },
+);
+
+1;
+;
diff --git a/SL/DB/MetaSetup/TimeRecordingArticle.pm b/SL/DB/MetaSetup/TimeRecordingArticle.pm
new file mode 100644 (file)
index 0000000..5d7bd84
--- /dev/null
@@ -0,0 +1,30 @@
+# This file has been auto-generated. Do not modify it; it will be overwritten
+# by rose_auto_create_model.pl automatically.
+package SL::DB::TimeRecordingArticle;
+
+use strict;
+
+use parent qw(SL::DB::Object);
+
+__PACKAGE__->meta->table('time_recording_articles');
+
+__PACKAGE__->meta->columns(
+  id       => { type => 'serial', not_null => 1 },
+  part_id  => { type => 'integer', not_null => 1 },
+  position => { type => 'integer', not_null => 1 },
+);
+
+__PACKAGE__->meta->primary_key_columns([ 'id' ]);
+
+__PACKAGE__->meta->unique_keys([ 'part_id' ]);
+
+__PACKAGE__->meta->foreign_keys(
+  part => {
+    class       => 'SL::DB::Part',
+    key_columns => { part_id => 'id' },
+    rel_type    => 'one to one',
+  },
+);
+
+1;
+;
index dc4bbeb..b3741df 100644 (file)
@@ -393,6 +393,7 @@ sub new_from {
                                                         marge_percent marge_price_factor marge_total
                                                         ordnumber parts_id price_factor price_factor_id pricegroup_id
                                                         project_id qty reqdate sellprice serialnumber ship subtotal transdate unit
+                                                        optional
                                                      )),
                                                  custom_variables => \@custom_variables,
     );
index 684c49b..2752de6 100644 (file)
@@ -171,6 +171,16 @@ SQL
   return $customers;
 }
 
+sub check_for_open_invoices {
+  my ($self) = @_;
+    my $open_invoices = SL::DB::Manager::Invoice->get_all_count(
+      query => [customer_id => $self->{kivi_customer_id},
+              paid => {lt_sql => 'amount'},
+      ],
+    );
+  return $open_invoices;
+}
+
 sub get_customer{
   my ($self, %params) = @_;
   my $shop = SL::DB::Manager::Shop->find_by(id => $self->shop_id);
diff --git a/SL/DB/TimeRecording.pm b/SL/DB/TimeRecording.pm
new file mode 100644 (file)
index 0000000..13329b7
--- /dev/null
@@ -0,0 +1,133 @@
+# 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::TimeRecording;
+
+use strict;
+
+use SL::Locale::String qw(t8);
+
+use SL::DB::Helper::AttrDuration;
+use SL::DB::Helper::AttrHTML;
+
+use SL::DB::MetaSetup::TimeRecording;
+use SL::DB::Manager::TimeRecording;
+
+__PACKAGE__->meta->initialize;
+
+__PACKAGE__->attr_duration_minutes(qw(duration));
+
+__PACKAGE__->attr_html('description');
+
+__PACKAGE__->before_save('_before_save_check_valid');
+
+sub _before_save_check_valid {
+  my ($self) = @_;
+
+  my @errors = $self->validate;
+  return (scalar @errors == 0);
+}
+
+sub validate {
+  my ($self) = @_;
+
+  my @errors;
+
+  push @errors, t8('Customer must not be empty.')                              if !$self->customer_id;
+  push @errors, t8('Staff member must not be empty.')                          if !$self->staff_member_id;
+  push @errors, t8('Employee must not be empty.')                              if !$self->employee_id;
+  push @errors, t8('Description must not be empty.')                           if !$self->description;
+  push @errors, t8('Start time must be earlier than end time.')                if $self->is_time_in_wrong_order;
+
+  my $conflict = $self->is_time_overlapping;
+  push @errors, t8('Entry overlaps with "#1".', $conflict->displayable_times)  if $conflict;
+
+  return @errors;
+}
+
+sub is_time_overlapping {
+  my ($self) = @_;
+
+  # Do not allow overlapping time periods.
+  # Start time can be equal to another end time
+  # (an end time can be equal to another start time)
+
+  # We cannot check if no staff member is given.
+  return if !$self->staff_member_id;
+
+  # If no start time and no end time are given, there is no overlapping.
+  return if !($self->start_time || $self->end_time);
+
+  my $conflicting;
+
+  # Start time or end time can be undefined.
+  if (!$self->start_time) {
+    $conflicting = SL::DB::Manager::TimeRecording->get_all(where  => [ and => [ '!id'           => $self->id,
+                                                                                staff_member_id => $self->staff_member_id,
+                                                                                start_time      => {lt => $self->end_time},
+                                                                                end_time        => {ge => $self->end_time} ] ],
+                                                           sort_by => 'start_time DESC',
+                                                           limit   => 1);
+  } elsif (!$self->end_time) {
+    $conflicting = SL::DB::Manager::TimeRecording->get_all(where  => [ and => [ '!id'           => $self->id,
+                                                                                staff_member_id => $self->staff_member_id,
+                                                                                or              => [ and => [start_time => {le => $self->start_time},
+                                                                                                             end_time   => {gt => $self->start_time} ],
+                                                                                                     start_time => $self->start_time,
+                                                                                ],
+                                                                       ],
+                                                           ],
+                                                           sort_by => 'start_time DESC',
+                                                           limit   => 1);
+  } else {
+    $conflicting = SL::DB::Manager::TimeRecording->get_all(where  => [ and => [ '!id'           => $self->id,
+                                                                                staff_member_id => $self->staff_member_id,
+                                                                                or              => [ and => [ start_time => {lt => $self->end_time},
+                                                                                                              end_time   => {gt => $self->start_time} ] ,
+                                                                                                     or  => [ start_time => $self->start_time,
+                                                                                                              end_time   => $self->end_time, ],
+                                                                                ]
+                                                                       ]
+                                                           ],
+                                                           sort_by => 'start_time DESC',
+                                                           limit   => 1);
+  }
+
+  return $conflicting->[0] if @$conflicting;
+  return;
+}
+
+sub is_time_in_wrong_order {
+  my ($self) = @_;
+
+  if ($self->start_time && $self->end_time
+      && $self->start_time >= $self->end_time) {
+    return 1;
+  }
+
+  return;
+}
+
+sub is_duration_used {
+  return !$_[0]->start_time;
+}
+
+sub displayable_times {
+  my ($self) = @_;
+
+  my $text;
+
+  if ($self->is_duration_used) {
+    $text = $self->date_as_date . ': ' . ($self->duration_as_duration_string || '--:--');
+
+  } else {
+    # placeholder
+    my $ph =  $::locale->format_date_object(DateTime->new(year => 1111, month => 11, day => 11, hour => 11, minute => 11), precision => 'minute');
+    $ph    =~ s{1}{-}g;
+    $text  =  ($self->start_time_as_timestamp||$ph) . ' - ' . ($self->end_time_as_timestamp||$ph);
+  }
+
+  return $text;
+}
+
+1;
diff --git a/SL/DB/TimeRecordingArticle.pm b/SL/DB/TimeRecordingArticle.pm
new file mode 100644 (file)
index 0000000..6348378
--- /dev/null
@@ -0,0 +1,16 @@
+# 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::TimeRecordingArticle;
+
+use strict;
+
+use SL::DB::MetaSetup::TimeRecordingArticle;
+use SL::DB::Manager::TimeRecordingArticle;
+
+use SL::DB::Helper::ActsAsList;
+
+__PACKAGE__->meta->initialize;
+
+
+1;
index 90599a7..3b0b41b 100644 (file)
--- a/SL/DO.pm
+++ b/SL/DO.pm
@@ -649,6 +649,38 @@ sub delete {
   return $rc;
 }
 
+sub delete_transfers {
+  $main::lxdebug->enter_sub();
+
+  my ($self)   = @_;
+
+  my $myconfig = \%main::myconfig;
+  my $form     = $main::form;
+
+  my $rc = SL::DB::Order->new->db->with_transaction(sub {
+
+    my $do = SL::DB::DeliveryOrder->new(id => $form->{id})->load;
+    die "No valid delivery order found" unless ref $do eq 'SL::DB::DeliveryOrder';
+
+    my $dt = DateTime->today->subtract(days => $::instance_conf->get_undo_transfer_interval);
+    croak "Wrong call. Please check undoing interval" unless DateTime->compare($do->itime, $dt) == 1;
+
+    foreach my $doi (@{ $do->orderitems }) {
+      foreach my $dois (@{ $doi->delivery_order_stock_entries}) {
+        $dois->inventory->delete;
+        $dois->delete;
+      }
+    }
+    $do->update_attributes(delivered => 0);
+
+    1;
+  });
+
+  $main::lxdebug->leave_sub();
+
+  return $rc;
+}
+
 sub retrieve {
   $main::lxdebug->enter_sub();
 
index b702b0f..8bf30a5 100644 (file)
@@ -9,10 +9,11 @@ use SL::Dev::Inventory;
 use SL::Dev::Record;
 use SL::Dev::Payment;
 use SL::Dev::Shop;
+use SL::Dev::TimeRecording;
 
 sub import {
   no strict "refs";
-  for (qw(Part CustomerVendor Inventory Record Payment Shop)) {
+  for (qw(Part CustomerVendor Inventory Record Payment Shop TimeRecording)) {
     Exporter::export_to_level("SL::Dev::$_", 1, @_);
   }
 }
diff --git a/SL/Dev/TimeRecording.pm b/SL/Dev/TimeRecording.pm
new file mode 100644 (file)
index 0000000..825472f
--- /dev/null
@@ -0,0 +1,41 @@
+package SL::Dev::TimeRecording;
+
+use strict;
+use base qw(Exporter);
+our @EXPORT_OK = qw(new_time_recording);
+our %EXPORT_TAGS = (ALL => \@EXPORT_OK);
+
+use DateTime;
+
+use SL::DB::TimeRecording;
+
+use SL::DB::Employee;
+use SL::Dev::CustomerVendor qw(new_customer);
+
+
+sub new_time_recording {
+  my (%params) = @_;
+
+  my $customer = delete $params{customer} // new_customer(name => 'Testcustomer')->save;
+  die "illegal customer" unless defined $customer && ref($customer) eq 'SL::DB::Customer';
+
+  my $employee     = $params{employee}     // SL::DB::Manager::Employee->current;
+  my $staff_member = $params{staff_member} // $employee;
+
+  my $now = DateTime->now_local;
+
+  my $time_recording = SL::DB::TimeRecording->new(
+    start_time   => $now,
+    end_time     => $now->add(hours => 1),
+    customer     => $customer,
+    description  => '<p>this and that</p>',
+    staff_member => $staff_member,
+    employee     => $employee,
+    %params,
+  );
+
+  return $time_recording;
+}
+
+
+1;
index 2a61422..921cb25 100644 (file)
@@ -2958,11 +2958,13 @@ sub save_status {
 # $main::locale->text('ELSE')
 # $main::locale->text('SAVED FOR DUNNING')
 # $main::locale->text('DUNNING STARTED')
+# $main::locale->text('PREVIEWED')
 # $main::locale->text('PRINTED')
 # $main::locale->text('MAILED')
 # $main::locale->text('SCREENED')
 # $main::locale->text('CANCELED')
 # $main::locale->text('IMPORT')
+# $main::locale->text('UNDO TRANSFER')
 # $main::locale->text('UNIMPORT')
 # $main::locale->text('invoice')
 # $main::locale->text('proforma')
index 35b5d6e..4a7b245 100644 (file)
@@ -77,7 +77,7 @@ my @alpha_2_mappings = (
   [ 'EG', qr{^(?:EG|Egypt)$}i ],
   [ 'EH', qr{^(?:EH|Western Sahara)$}i ],
   [ 'ER', qr{^(?:ER|Eritrea)$}i ],
-  [ 'ES', qr{^(?:ES|Spain)$}i ],
+  [ 'ES', qr{^(?:ES|Spain|Spanien)$}i ],
   [ 'ET', qr{^(?:ET|Ethiopia)$}i ],
   [ 'FI', qr{^(?:FI|Finland)$}i ],
   [ 'FJ', qr{^(?:FJ|Fiji)$}i ],
@@ -119,7 +119,7 @@ my @alpha_2_mappings = (
   [ 'IQ', qr{^(?:IQ|Iraq)$}i ],
   [ 'IR', qr{^(?:IR|Iran \(Islamic Republic of\)|Iran)$}i ],
   [ 'IS', qr{^(?:IS|Iceland)$}i ],
-  [ 'IT', qr{^(?:IT|Italy)$}i ],
+  [ 'IT', qr{^(?:IT|Italy|Italien)$}i ],
   [ 'JE', qr{^(?:JE|Jersey)$}i ],
   [ 'JM', qr{^(?:JM|Jamaica)$}i ],
   [ 'JO', qr{^(?:JO|Jordan)$}i ],
@@ -143,7 +143,7 @@ my @alpha_2_mappings = (
   [ 'LR', qr{^(?:LR|Liberia)$}i ],
   [ 'LS', qr{^(?:LS|Lesotho)$}i ],
   [ 'LT', qr{^(?:LT|Lithuania)$}i ],
-  [ 'LU', qr{^(?:LU|Luxembourg)$}i ],
+  [ 'LU', qr{^(?:LU|Luxembourg|Luxemburg)$}i ],
   [ 'LV', qr{^(?:LV|Latvia)$}i ],
   [ 'LY', qr{^(?:LY|Libya)$}i ],
   [ 'MA', qr{^(?:MA|Morocco)$}i ],
@@ -175,7 +175,7 @@ my @alpha_2_mappings = (
   [ 'NF', qr{^(?:NF|Norfolk Island)$}i ],
   [ 'NG', qr{^(?:NG|Nigeria)$}i ],
   [ 'NI', qr{^(?:NI|Nicaragua)$}i ],
-  [ 'NL', qr{^(?:NL|Netherlands)$}i ],
+  [ 'NL', qr{^(?:NL|Netherlands|Niederlande)$}i ],
   [ 'NO', qr{^(?:NO|Norway)$}i ],
   [ 'NP', qr{^(?:NP|Nepal)$}i ],
   [ 'NR', qr{^(?:NR|Nauru)$}i ],
diff --git a/SL/Helper/UserPreferences/TimeRecording.pm b/SL/Helper/UserPreferences/TimeRecording.pm
new file mode 100644 (file)
index 0000000..7686a7c
--- /dev/null
@@ -0,0 +1,69 @@
+package SL::Helper::UserPreferences::TimeRecording;
+
+use strict;
+use parent qw(Rose::Object);
+
+use Carp;
+use List::MoreUtils qw(none);
+
+use SL::Helper::UserPreferences;
+
+use Rose::Object::MakeMethods::Generic (
+  'scalar --get_set_init' => [ qw(user_prefs) ],
+);
+
+sub get_use_duration {
+  !!$_[0]->user_prefs->get('use_duration');
+}
+
+sub store_use_duration {
+  $_[0]->user_prefs->store('use_duration', $_[1]);
+}
+
+sub init_user_prefs {
+  SL::Helper::UserPreferences->new(
+    namespace => $_[0]->namespace,
+  )
+}
+
+# read only stuff
+sub namespace     { 'TimeRecording' }
+sub version       { 1 }
+
+1;
+
+__END__
+
+=pod
+
+=encoding utf-8
+
+=head1 NAME
+
+SL::Helper::UserPreferences::TimeRecording - preferences intended
+to store user settings for using the time recording functionality.
+
+=head1 SYNOPSIS
+
+  use SL::Helper::UserPreferences::TimeRecording;
+  my $prefs = SL::Helper::UserPreferences::TimeRecording->new();
+
+  $prefs->store_use_duration(1);
+  my $value = $prefs->get_use_duration;
+
+=head1 DESCRIPTION
+
+This module manages storing the user's choise for settings for
+the time recording controller.
+For now it can be choosen if an entry is done by entering start and
+end time or a date and a duration.
+
+=head1 BUGS
+
+None yet :)
+
+=head1 AUTHOR
+
+Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>
+
+=cut
index 5ddf2c8..f36ca04 100644 (file)
--- a/SL/IC.pm
+++ b/SL/IC.pm
@@ -526,13 +526,13 @@ sub all_parts {
   if ($form->{bom} eq '2' && $form->{l_assembly}) {
     # nuke where clause and bind vars
     $where_clause = ' 1=1 AND p.id in (SELECT id from assembly where parts_id IN ' .
-                    ' (select id from parts where 1=1 AND ';
+                    ' (select id from parts where 1=1';
     @bind_vars    = ();
     # use only like filter for items used in assemblies
     foreach (@like_filters) {
       next unless $form->{$_};
       $form->{"l_$_"} = '1'; # show the column
-      $where_clause .= " $_ ILIKE ? ";
+      $where_clause .= " AND $_ ILIKE ? ";
       push @bind_vars,    like($form->{$_});
     }
     $where_clause .='))';
@@ -547,7 +547,6 @@ sub all_parts {
     $order_clause
     $limit_clause
   SQL
-
   $form->{parts} = selectall_hashref_query($form, $dbh, $query, @bind_vars);
 
   map { $_->{onhand} *= 1 } @{ $form->{parts} };
index 5fe66cf..7cf8008 100644 (file)
--- a/SL/IS.pm
+++ b/SL/IS.pm
@@ -1441,6 +1441,21 @@ SQL
     }
   }
 
+  # update shop status
+  my $invoice = SL::DB::Invoice->new( id => $form->{id} )->load;
+  my @linked_shop_orders = $invoice->linked_records(
+    from      => 'ShopOrder',
+    via       => ['DeliveryOrder','Order',],
+  );
+    #do update
+    my $shop_order = $linked_shop_orders[0][0];
+  if ( $shop_order ) {
+    require SL::Shop;
+    my $shop_config = SL::DB::Manager::Shop->get_first( query => [ id => $shop_order->shop_id ] );
+    my $shop = SL::Shop->new( config => $shop_config );
+    $shop->connector->set_orderstatus($shop_order->shop_trans_id, "completed");
+  }
+
   return 1;
 }
 
index aeac4e5..fedc588 100644 (file)
@@ -48,6 +48,7 @@ BEGIN {
   { name => "List::UtilsBy",   version => '0.09',  url => "http://search.cpan.org/~pevans/",    debian => 'liblist-utilsby-perl' },
   { name => "LWP::Authen::Digest",                 url => "http://search.cpan.org/~gaas/",      debian => 'libwww-perl', dist_name => 'libwww-perl' },
   { name => "LWP::UserAgent",                      url => "http://search.cpan.org/~gaas/",      debian => 'libwww-perl', dist_name => 'libwww-perl' },
+  { name => "Math::Round",                         url => "https://metacpan.org/pod/Math::Round", debian => 'libmath-round-perl' },
   { name => "Params::Validate",                    url => "http://search.cpan.org/~drolsky/",   debian => 'libparams-validate-perl' },
   { name => "PBKDF2::Tiny",    version => '0.005', url => "http://search.cpan.org/~dagolden/",  debian => 'libpbkdf2-tiny-perl' },
   { name => "PDF::API2",       version => '2.000', url => "http://search.cpan.org/~areibens/",  debian => 'libpdf-api2-perl' },
index 1b640e3..6b48312 100644 (file)
--- a/SL/OE.pm
+++ b/SL/OE.pm
@@ -1366,7 +1366,7 @@ sub order_details {
        partnotes serialnumber reqdate sellprice sellprice_nofmt listprice listprice_nofmt netprice netprice_nofmt
        discount discount_nofmt p_discount discount_sub discount_sub_nofmt nodiscount_sub nodiscount_sub_nofmt
        linetotal linetotal_nofmt nodiscount_linetotal nodiscount_linetotal_nofmt tax_rate projectnumber projectdescription
-       price_factor price_factor_name partsgroup weight weight_nofmt lineweight lineweight_nofmt);
+       price_factor price_factor_name partsgroup weight weight_nofmt lineweight lineweight_nofmt optional);
 
   push @arrays, map { "ic_cvar_$_->{name}" } @{ $ic_cvar_configs };
   push @arrays, map { "project_cvar_$_->{name}" } @{ $project_cvar_configs };
@@ -1433,6 +1433,7 @@ sub order_details {
       push @{ $form->{TEMPLATE_ARRAYS}->{price_factor} },      $price_factor->{formatted_factor};
       push @{ $form->{TEMPLATE_ARRAYS}->{price_factor_name} }, $price_factor->{description};
       push @{ $form->{TEMPLATE_ARRAYS}->{partsgroup} },        $form->{"partsgroup_$i"};
+      push @{ $form->{TEMPLATE_ARRAYS}->{optional} },          $form->{"optional_$i"};
 
       my $sellprice     = $form->parse_amount($myconfig, $form->{"sellprice_$i"});
       my ($dec)         = ($sellprice =~ /\.(\d+)/);
@@ -1472,7 +1473,7 @@ sub order_details {
         $form->{non_separate_subtotal} += $linetotal;
       }
 
-      $form->{ordtotal}         += $linetotal;
+      $form->{ordtotal}         += $linetotal unless $form->{"optional_$i"};
       $form->{nodiscount_total} += $nodiscount_linetotal;
       $form->{discount_total}   += $discount;
 
@@ -1520,14 +1521,16 @@ sub order_details {
 
       map { $taxrate += $form->{"${_}_rate"} } split(/ /, $form->{"taxaccounts_$i"});
 
-      if ($form->{taxincluded}) {
+      unless ($form->{"optional_$i"}) {
+        if ($form->{taxincluded}) {
 
-        # calculate tax
-        $taxamount = $linetotal * $taxrate / (1 + $taxrate);
-        $taxbase = $linetotal / (1 + $taxrate);
-      } else {
-        $taxamount = $linetotal * $taxrate;
-        $taxbase   = $linetotal;
+          # calculate tax
+          $taxamount = $linetotal * $taxrate / (1 + $taxrate);
+          $taxbase = $linetotal / (1 + $taxrate);
+        } else {
+          $taxamount = $linetotal * $taxrate;
+          $taxbase   = $linetotal;
+        }
       }
 
       if ($taxamount != 0) {
index af40c30..8aecdfe 100644 (file)
@@ -39,7 +39,7 @@ sub project_picker {
   push @classes, 'project_autocomplete';
 
 
-  my %data_params = map { $_ => delete $params{$_}  } grep { defined $params{$_} } qw(customer_id active valid);
+  my %data_params = map { $_ => delete $params{$_}  } grep { defined $params{$_} } qw(customer_id active valid description_style);
 
   my $ret =
     input_tag($name, (ref $value && $value->can('id') ? $value->id : ''), class => "@classes", type => 'hidden', id => $id,
index 75180a9..275e2e3 100644 (file)
@@ -486,6 +486,7 @@ sub generate_pdf_content {
   };
 
   my $self       = shift;
+  my %params     = @_;
   my $variables  = $self->prepare_html_content();
   my $form       = $self->{form};
   my $myconfig   = $self->{myconfig};
@@ -718,13 +719,17 @@ sub generate_pdf_content {
     $content = $self->append_gl_pdf_attachments($form,$content);
   }
 
+  # 1. check if we return the report as binary pdf
+  if ($params{want_binary_pdf}) {
+    return $content;
+  }
+  # 2. check if we want and can directly print the report
   my $printer_command;
   if ($pdfopts->{print} && $pdfopts->{printer_id}) {
     $form->{printer_id} = $pdfopts->{printer_id};
     $form->get_printer_code($myconfig);
     $printer_command = $form->{printer_command};
   }
-
   if ($printer_command) {
     $self->_print_content('printer_command' => $printer_command,
                           'content'         => $content,
@@ -732,6 +737,7 @@ sub generate_pdf_content {
     $form->{report_generator_printed} = 1;
 
   } else {
+  # 3. default: redirect http with file attached
     my $filename = $self->get_attachment_basename();
 
     print qq|content-type: application/pdf\n|;
@@ -975,6 +981,11 @@ The html generation function. Is invoked by generate_with_headers.
 
 The PDF generation function. It is invoked by generate_with_headers and renders the PDF with the PDF::API2 library.
 
+If the param want_binary_pdf is set, the binary pdf stream will be returned.
+If $pdfopts->{print} && $pdfopts->{printer_id} are set, the pdf will be printed (output is directed to print command).
+
+Otherwise and the default a html form with a downloadable file is returned.
+
 =item generate_csv_content
 
 The CSV generation function. Uses XS_CSV to parse the information into csv.
index 19a712f..56127e4 100644 (file)
@@ -7,7 +7,7 @@ use Rose::Object::MakeMethods::Generic (
   scalar => [ qw(config) ],
 );
 
-sub get_order      { die 'get_order needs to be implemented' }
+sub get_one_order  { die 'get_one_order needs to be implemented' }
 
 sub get_new_orders { die 'get_order needs to be implemented' }
 
@@ -15,9 +15,11 @@ sub update_part    { die 'update_part needs to be implemented' }
 
 sub get_article    { die 'get_article needs to be implemented' }
 
-sub get_categories { die 'get_order needs to be implemented' }
+sub get_categories { die 'get_categories needs to be implemented' }
 
-sub get_version    { die 'get_order needs to be implemented' }
+sub get_version    { die 'get_version needs to be implemented' }
+
+sub set_orderstatus { die 'set_orderstatus needs to be implemented' }
 
 1;
 
@@ -38,7 +40,7 @@ __END__
 
 =over 4
 
-=item C<get_order>
+=item C<get_one_order>
 
 =item C<get_new_orders>
 
@@ -50,6 +52,8 @@ __END__
 
 =item C<get_version>
 
+=item C<set_orderstatus>
+
 =back
 
 =head1 SEE ALSO
index fdfd141..938f99b 100644 (file)
@@ -24,34 +24,100 @@ use Rose::Object::MakeMethods::Generic (
   'scalar --get_set_init' => [ qw(connector url) ],
 );
 
+sub get_one_order {
+  my ($self, $ordnumber) = @_;
+
+  my $dbh       = SL::DB::client;
+  my $of        = 0;
+  my $url       = $self->url;
+  my $data      = $self->connector->get($url . "api/orders/$ordnumber?useNumberAsId=true");
+  my @errors;
+
+  my %fetched_orders;
+  if ($data->is_success && $data->content_type eq 'application/json'){
+    my $data_json = $data->content;
+    my $import    = SL::JSON::decode_json($data_json);
+    my $shoporder = $import->{data};
+    $dbh->with_transaction( sub{
+      $self->import_data_to_shop_order($import);
+      1;
+    })or do {
+      push @errors,($::locale->text('Saving failed. Error message from the database: #1', $dbh->error));
+    };
+
+    if(!@errors){
+      $self->set_orderstatus($import->{data}->{id}, "fetched");
+      $of++;
+    }else{
+      flash_later('error', $::locale->text('Database errors: #1', @errors));
+    }
+    %fetched_orders = (shop_description => $self->config->description, number_of_orders => $of);
+  } else {
+    my %error_msg  = (
+      shop_id          => $self->config->id,
+      shop_description => $self->config->description,
+      message          => "Error: $data->status_line",
+      error            => 1,
+    );
+    %fetched_orders = %error_msg;
+  }
+
+  return \%fetched_orders;
+}
+
 sub get_new_orders {
   my ($self, $id) = @_;
 
   my $url              = $self->url;
-  my $ordnumber        = $self->config->last_order_number + 1;
+  my $last_order_number = $self->config->last_order_number;
   my $otf              = $self->config->orders_to_fetch;
   my $of               = 0;
-  my $orders_data      = $self->connector->get($url . "api/orders?limit=$otf&filter[0][property]=number&filter[0][expression]=>&filter[0][value]=" . $self->config->last_order_number);
-  my $orders_data_json = $orders_data->content;
-  my $orders_import    = SL::JSON::decode_json($orders_data_json);
-
-  if ($orders_import->{success}){
+  my $last_data      = $self->connector->get($url . "api/orders/$last_order_number?useNumberAsId=true");
+  my $last_data_json = $last_data->content;
+  my $last_import    = SL::JSON::decode_json($last_data_json);
+
+  my $orders_data      = $self->connector->get($url . "api/orders?limit=$otf&filter[1][property]=status&filter[1][value]=0&filter[0][property]=id&filter[0][expression]=>&filter[0][value]=" . $last_import->{data}->{id});
+
+  my $dbh = SL::DB->client;
+  my @errors;
+  my %fetched_orders;
+  if ($orders_data->is_success && $orders_data->content_type eq 'application/json'){
+    my $orders_data_json = $orders_data->content;
+    my $orders_import    = SL::JSON::decode_json($orders_data_json);
     foreach my $shoporder(@{ $orders_import->{data} }){
 
       my $data      = $self->connector->get($url . "api/orders/" . $shoporder->{id});
       my $data_json = $data->content;
       my $import    = SL::JSON::decode_json($data_json);
 
-      $self->import_data_to_shop_order($import);
-
-      $self->config->assign_attributes( last_order_number => $ordnumber);
-      $self->config->save;
-      $ordnumber++;
-      $of++;
+      $dbh->with_transaction( sub{
+          $self->import_data_to_shop_order($import);
+
+          $self->config->assign_attributes( last_order_number => $shoporder->{number});
+          $self->config->save;
+          1;
+      })or do {
+        push @errors,($::locale->text('Saving failed. Error message from the database: #1', $dbh->error));
+      };
+
+      if(!@errors){
+        $self->set_orderstatus($shoporder->{id}, "fetched");
+        $of++;
+      }else{
+        flash_later('error', $::locale->text('Database errors: #1', @errors));
+      }
     }
+    %fetched_orders = (shop_description => $self->config->description, number_of_orders => $of);
+  } else {
+    my %error_msg  = (
+      shop_id          => $self->config->id,
+      shop_description => $self->config->description,
+      message          => "Error: $orders_data->status_line",
+      error            => 1,
+    );
+    %fetched_orders = %error_msg;
   }
-  my $shop           = $self->config->description;
-  my %fetched_orders = (shop_id => $self->config->description, number_of_orders => $of);
+
   return \%fetched_orders;
 }
 
@@ -62,7 +128,8 @@ sub import_data_to_shop_order {
   $shop_order->save;
   my $id = $shop_order->id;
 
-  my @positions = sort { Sort::Naturally::ncmp($a->{"partnumber"}, $b->{"partnumber"}) } @{ $import->{data}->{details} };
+  my @positions = sort { Sort::Naturally::ncmp($a->{"articleNumber"}, $b->{"articleNumber"}) } @{ $import->{data}->{details} };
+  #my @positions = sort { Sort::Naturally::ncmp($a->{"partnumber"}, $b->{"partnumber"}) } @{ $import->{data}->{details} };
   my $position = 1;
   my $active_price_source = $self->config->price_source;
   #Mapping Positions
@@ -82,14 +149,14 @@ sub import_data_to_shop_order {
     $pos_insert->save;
     $position++;
   }
-  $shop_order->{positions} = $position-1;
+  $shop_order->positions($position-1);
 
   my $customer = $shop_order->get_customer;
 
   if(ref($customer)){
     $shop_order->kivi_customer_id($customer->id);
-    $shop_order->save;
   }
+  $shop_order->save;
 }
 
 sub map_data_to_shoporder {
@@ -103,6 +170,7 @@ sub map_data_to_shoporder {
 
   my $shop_id      = $self->config->id;
   my $tax_included = $self->config->pricetype;
+
   # Mapping to table shoporders. See http://community.shopware.com/_detail_1690.html#GET_.28Liste.29
   my %columns = (
     amount                  => $import->{data}->{invoiceAmount},
@@ -219,7 +287,7 @@ sub update_part {
   die unless ref($shop_part) eq 'SL::DB::ShopPart';
 
   my $url = $self->url;
-  my $part = SL::DB::Part->new(id => $shop_part->{part_id})->load;
+  my $part = SL::DB::Part->new(id => $shop_part->part_id)->load;
 
   # CVARS to map
   my $cvars = { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $part->cvars_by_config } };
@@ -238,7 +306,7 @@ sub update_part {
   my %shop_data;
 
   if($todo eq "price"){
-    %shop_data = ( mainDetail => { number   => $part->{partnumber},
+    %shop_data = ( mainDetail => { number   => $part->partnumber,
                                    prices   =>  [ { from             => 1,
                                                     price            => $price,
                                                     customerGroupKey => 'EK',
@@ -247,13 +315,13 @@ sub update_part {
                                   },
                  );
   }elsif($todo eq "stock"){
-    %shop_data = ( mainDetail => { number   => $part->{partnumber},
-                                   inStock  => $part->{onhand},
+    %shop_data = ( mainDetail => { number   => $part->partnumber,
+                                   inStock  => $part->onhand,
                                  },
                  );
   }elsif($todo eq "price_stock"){
-    %shop_data =  ( mainDetail => { number   => $part->{partnumber},
-                                    inStock  => $part->{onhand},
+    %shop_data =  ( mainDetail => { number   => $part->partnumber,
+                                    inStock  => $part->onhand,
                                     prices   =>  [ { from             => 1,
                                                      price            => $price,
                                                      customerGroupKey => 'EK',
@@ -262,15 +330,15 @@ sub update_part {
                                    },
                    );
   }elsif($todo eq "active"){
-    %shop_data =  ( mainDetail => { number   => $part->{partnumber},
+    %shop_data =  ( mainDetail => { number   => $part->partnumber,
                                    },
-                    active => ($part->{partnumber} == 1 ? 0 : 1),
+                    active => ($part->partnumber == 1 ? 0 : 1),
                    );
   }elsif($todo eq "all"){
   # mapping to shopware still missing attributes,metatags
-    %shop_data =  (   name              => $part->{description},
-                      mainDetail        => { number   => $part->{partnumber},
-                                             inStock  => $part->{onhand},
+    %shop_data =  (   name              => $part->description,
+                      mainDetail        => { number   => $part->partnumber,
+                                             inStock  => $part->onhand,
                                              prices   =>  [ {          from   => 1,
                                                                        price  => $price,
                                                             customerGroupKey  => 'EK',
@@ -280,12 +348,12 @@ sub update_part {
                                              #attribute => { attr1  => $cvars->{CVARNAME}->{value}, } , #HowTo handle attributes
                                        },
                       supplier          => 'AR', # Is needed by shopware,
-                      descriptionLong   => $shop_part->{shop_description},
+                      descriptionLong   => $shop_part->shop_description,
                       active            => $shop_part->active,
                       images            => [ @upload_img ],
                       __options_images  => { replace => 1, },
                       categories        => [ @cat ],
-                      description       => $shop_part->{shop_description},
+                      description       => $shop_part->shop_description,
                       categories        => [ @cat ],
                       tax               => $taxrate,
                     )
@@ -298,7 +366,7 @@ sub update_part {
   my $upload_content;
   my $upload;
   my ($import,$data,$data_json);
-  my $partnumber = $::form->escape($part->{partnumber});#shopware don't accept / in articlenumber
+  my $partnumber = $::form->escape($part->partnumber);#shopware don't accept / in articlenumber
   # Shopware RestApi sends an erroremail if configured and part not found. But it needs this info to decide if update or create a new article
   # LWP->post = create LWP->put = update
     $data       = $self->connector->get($url . "api/articles/$partnumber?useNumberAsId=true");
@@ -306,7 +374,7 @@ sub update_part {
     $import     = SL::JSON::decode_json($data_json);
   if($import->{success}){
     #update
-    my $partnumber  = $::form->escape($part->{partnumber});#shopware don't accept / in articlenumber
+    my $partnumber  = $::form->escape($part->partnumber);#shopware don't accept / in articlenumber
     $upload         = $self->connector->put($url . "api/articles/$partnumber?useNumberAsId=true", Content => $dataString);
     my $data_json   = $upload->content;
     $upload_content = SL::JSON::decode_json($data_json);
@@ -318,7 +386,7 @@ sub update_part {
   }
   # don't know if this is needed
   if(@upload_img) {
-    my $partnumber = $::form->escape($part->{partnumber});#shopware don't accept / in articlenumber
+    my $partnumber = $::form->escape($part->partnumber);#shopware don't accept / in articlenumber
     my $imgup      = $self->connector->put($url . "api/generatearticleimages/$partnumber?useNumberAsId=true");
   }
 
@@ -335,6 +403,15 @@ sub get_article {
   return SL::JSON::decode_json($data_json);
 }
 
+sub set_orderstatus {
+  my ($self,$order_id, $status) = @_;
+  if ($status eq "fetched") { $status = 1; }
+  if ($status eq "completed") { $status = 2; }
+  my %new_status = (orderStatusId => $status);
+  my $new_status_json = SL::JSON::to_json(\%new_status);
+  $self->connector->put($self->url . "api/orders/$order_id", Content => $new_status_json);
+}
+
 sub init_url {
   my ($self) = @_;
   $self->url($self->config->protocol . "://" . $self->config->server . ":" . $self->config->port . $self->config->path);
@@ -377,8 +454,14 @@ for more information.
 
 =over 4
 
+=item C<get_one_order>
+
+Fetches one order specified by ordnumber
+
 =item C<get_new_orders>
 
+Fetches new order by parameters from shop configuration
+
 =item C<import_data_to_shop_order>
 
 Creates on shoporder object from json
index d041561..0b35fc7 100644 (file)
@@ -664,6 +664,7 @@ sub config {
   $form->{purchase_search_makemodel}        = AM->purchase_search_makemodel();
   $form->{sales_search_customer_partnumber} = AM->sales_search_customer_partnumber();
   $form->{positions_show_update_button}     = AM->positions_show_update_button();
+  $form->{time_recording_use_duration}      = AM->time_recording_use_duration();
 
   $myconfig{show_form_details} = 1 unless (defined($myconfig{show_form_details}));
   $form->{CAN_CHANGE_PASSWORD} = $main::auth->can_change_password();
index 51fbb1a..d88cb2d 100644 (file)
@@ -1001,6 +1001,7 @@ sub ar_transactions {
 
   my ($callback, $href, @columns);
 
+  my %params   = @_;
   report_generator_set_default_sort('transdate', 1);
 
   AR->ar_transactions(\%myconfig, \%$form);
@@ -1027,7 +1028,7 @@ sub ar_transactions {
                                            employee_id salesman_id business_id parts_partnumber parts_description department_id show_marked_as_closed show_not_mailed);
   push @hidden_variables, map { "cvar_$_->{name}" } @ct_searchable_custom_variables;
 
-  $href = build_std_url('action=ar_transactions', grep { $form->{$_} } @hidden_variables);
+  $href =  $params{want_binary_pdf} ? '' : build_std_url('action=ar_transactions', grep { $form->{$_} } @hidden_variables);
 
   my %column_defs = (
     'ids'                     => { raw_header_data => SL::Presenter::Tag::checkbox_tag("", id => "check_all", checkall => "[data-checkall=1]"), align => 'center' },
@@ -1207,7 +1208,7 @@ sub ar_transactions {
     }
 
     $row->{invnumber}->{link} = build_std_url("script=" . ($ar->{invoice} ? 'is.pl' : 'ar.pl'), 'action=edit')
-      . "&id=" . E($ar->{id}) . "&callback=${callback}";
+      . "&id=" . E($ar->{id}) . "&callback=${callback}" unless $params{want_binary_pdf};
 
     $row->{ids} = {
       raw_data =>  SL::Presenter::Tag::checkbox_tag("id[]", value => $ar->{id}, "data-checkall" => 1),
@@ -1231,6 +1232,11 @@ sub ar_transactions {
   $report->add_separator();
   $report->add_data(create_subtotal_row(\%totals, \@columns, \%column_alignment, \@subtotal_columns, 'listtotal'));
 
+  if ($params{want_binary_pdf}) {
+    $report->generate_with_headers();
+    return $report->generate_pdf_content(want_binary_pdf => 1);
+  }
+
   $::request->layout->add_javascripts('kivi.MassInvoiceCreatePrint.js');
   setup_ar_transactions_action_bar(num_rows => scalar(@{ $form->{AR} }));
 
index fc38c52..4f55310 100644 (file)
@@ -241,6 +241,13 @@ sub setup_do_action_bar {
   my @req_trans_desc = qw(kivi.SalesPurchase.check_transaction_description) x!!$::instance_conf->get_require_transaction_description_ps;
   my $is_customer    = $::form->{vc} eq 'customer';
 
+  my $undo_date  = DateTime->today->subtract(days => $::instance_conf->get_undo_transfer_interval);
+  my $insertdate = DateTime->from_kivitendo($::form->{insertdate});
+  my $undo_transfer  = 0;
+  if (ref $undo_date eq 'DateTime' && ref $insertdate eq 'DateTime') {
+    # DateTime->compare      it returns 1 if $dt1 > $dt2
+    $undo_transfer = DateTime->compare($insertdate, $undo_date) == 1 ? 1 : 0;
+  }
   for my $bar ($::request->layout->get('actionbar')) {
     $bar->add(
       action =>
@@ -314,6 +321,13 @@ sub setup_do_action_bar {
           disabled => $::form->{delivered} ? t8('This record has already been delivered.') : undef,
           only_if  => !$is_customer && $::instance_conf->get_transfer_default,
         ],
+        action => [
+          t8('Undo Transfer'),
+          submit   => [ '#form', { action => "delete_transfers" } ],
+          checks   => [ 'kivi.validate_form' ],
+          only_if  => $::form->{delivered},
+          disabled => !$undo_transfer ? t8('Transfer date exceeds the maximum allowed interval.') : undef,
+        ],
       ], # end of combobox "Transfer out"
 
 
@@ -969,6 +983,37 @@ sub delete {
 
   $main::lxdebug->leave_sub();
 }
+sub delete_transfers {
+  $main::lxdebug->enter_sub();
+
+  check_do_access();
+
+  my $form     = $main::form;
+  my %myconfig = %main::myconfig;
+  my $locale   = $main::locale;
+  my $ret;
+
+  die "Invalid form type" unless $form->{type} =~ m/^(sales|purchase)_delivery_order$/;
+
+  if ($ret = DO->delete_transfers()) {
+    # saving the history
+    if(!exists $form->{addition}) {
+      $form->{snumbers} = qq|donumber_| . $form->{donumber};
+      $form->{addition} = "UNDO TRANSFER";
+      $form->save_history;
+    }
+    # /saving the history
+
+    flash_later('info', $locale->text("Transfer undone."));
+
+    $form->{callback} = 'do.pl?action=edit&type=' . $form->{type} . '&id=' . $form->escape($form->{id});
+    $form->redirect;
+  }
+
+  $form->error($locale->text('Cannot undo delivery order transfer!') . $ret);
+
+  $main::lxdebug->leave_sub();
+}
 
 sub invoice {
   $main::lxdebug->enter_sub();
@@ -1042,7 +1087,8 @@ sub invoice {
     if (my $order = SL::DB::Manager::Order->find_by(ordnumber => $form->{ordnumber}, $vc_id => $form->{"$vc_id"})) {
       $order->load;
       $form->{orddate} = $order->transdate_as_date;
-      $form->{$_}      = $order->$_ for qw(payment_id salesman_id taxzone_id quonumber);
+      $form->{$_}      = $order->$_ for qw(payment_id salesman_id taxzone_id quonumber taxincluded);
+      $form->{taxincluded_changed_by_user} = 1;
     }
   }
 
@@ -1578,6 +1624,7 @@ sub transfer_in {
 
   SL::DB::DeliveryOrder->new(id => $form->{id})->load->update_attributes(delivered => 1);
 
+  flash_later('info', $locale->text("Transfer successful"));
   $form->{callback} = 'do.pl?action=edit&type=purchase_delivery_order&id=' . $form->escape($form->{id});
   $form->redirect;
 
@@ -1696,6 +1743,7 @@ sub transfer_out {
 
   SL::DB::DeliveryOrder->new(id => $form->{id})->load->update_attributes(delivered => 1);
 
+  flash_later('info', $locale->text("Transfer successful"));
   $form->{callback} = 'do.pl?action=edit&type=sales_delivery_order&id=' . $form->escape($form->{id});
   $form->redirect;
 
index 79e5085..16aaf77 100644 (file)
@@ -1365,8 +1365,9 @@ sub post_transaction {
       die "guru meditation error: Can only assign amount to one bank account booking" if scalar @{ $payment } > 1;
 
       # credit/debit * -1 matches the sign for bt.amount and bt.invoice_amount
-      die "Can only assign the full (partial) bank amount to a single general ledger booking"
-        unless $bt->not_assigned_amount == $payment->[0]->amount * -1;
+
+      die "Can only assign the full (partial) bank amount to a single general ledger booking" . $bt->not_assigned_amount . " " .  ($payment->[0]->amount * -1)
+        unless (abs($bt->not_assigned_amount - ($payment->[0]->amount * -1)) < 0.001);
 
       $bt->update_attributes(invoice_amount => $bt->invoice_amount + ($payment->[0]->amount * -1));
 
index 0a6b861..7c7fa49 100644 (file)
@@ -2021,7 +2021,7 @@ sub setup_sales_purchase_print_options {
 }
 
 sub _get_files_for_email_dialog {
-  my %files = map { ($_ => []) } qw(versions files vc_files part_files);
+  my %files = map { ($_ => []) } qw(versions files vc_files part_files project_files);
 
   return %files if !$::instance_conf->get_doc_storage;
 
@@ -2030,6 +2030,8 @@ sub _get_files_for_email_dialog {
     $files{files}    = [ SL::File->get_all(         object_id => $::form->{id},    object_type => $::form->{type}, file_type => 'attachment') ];
     $files{vc_files} = [ SL::File->get_all(         object_id => $::form->{vc_id}, object_type => $::form->{vc},   file_type => 'attachment') ]
       if $::form->{vc} && $::form->{"vc_id"};
+    $files{project_files} = [ SL::File->get_all(object_id => $::form->{project_id}, object_type => 'project',file_type => 'attachment') ]
+      if $::form->{project_id};
   }
 
   my @parts =
index 48ff6c3..da168a2 100644 (file)
@@ -349,7 +349,7 @@ sub setup_ir_action_bar {
         action => [ t8('more') ],
         action => [
           t8('History'),
-          call     => [ 'set_history_window', $::form->{id} * 1, 'id', 'glid' ],
+          call     => [ 'set_history_window', $::form->{id} * 1, 'glid' ],
           disabled => !$form->{id} ? t8('This invoice has not been posted yet.') : undef,
         ],
         action => [
index 254d016..2f3803b 100644 (file)
@@ -1006,14 +1006,13 @@ sub orders {
   my $locale   = $main::locale;
   my $cgi      = $::request->{cgi};
 
+  my %params   = @_;
   check_oe_access();
 
   my $ordnumber = ($form->{type} =~ /_order$/) ? "ordnumber" : "quonumber";
 
   ($form->{ $form->{vc} }, $form->{"$form->{vc}_id"}) = split(/--/, $form->{ $form->{vc} });
-
   report_generator_set_default_sort('transdate', 1);
-
   OE->transactions(\%myconfig, \%$form);
 
   $form->{rowcount} = scalar @{ $form->{OE} };
@@ -1090,7 +1089,7 @@ sub orders {
   my   @keys_for_url = grep { $form->{$_} } @hidden_variables;
   push @keys_for_url, 'taxzone_id' if $form->{taxzone_id} ne ''; # taxzone_id could be 0
 
-  my $href = build_std_url('action=orders', @keys_for_url);
+  my $href = $params{want_binary_pdf} ? '' : build_std_url('action=orders', @keys_for_url);
 
   my %column_defs = (
     'ids'                     => { 'text' => '', },
@@ -1238,10 +1237,11 @@ sub orders {
 
   my $idx = 1;
 
-  my $edit_url = ($::instance_conf->get_feature_experimental_order)
+  my $edit_url = $params{want_binary_pdf}
+               ? ''
+               : ($::instance_conf->get_feature_experimental_order)
                ? build_std_url('script=controller.pl', 'action=Order/edit', 'type')
                : build_std_url('action=edit', 'type', 'vc');
-
   foreach my $oe (@{ $form->{OE} }) {
     map { $oe->{$_} *= $oe->{exchangerate} } @subtotal_columns;
 
@@ -1277,7 +1277,7 @@ sub orders {
       'align'    => 'center',
     };
 
-    $row->{$ordnumber}->{link} = $edit_url . "&id=" . E($oe->{id}) . "&callback=${callback}";
+    $row->{$ordnumber}->{link} = $edit_url . "&id=" . E($oe->{id}) . "&callback=${callback}" unless $params{want_binary_pdf};
 
     my $row_set = [ $row ];
 
@@ -1294,7 +1294,10 @@ sub orders {
 
   $report->add_separator();
   $report->add_data(create_subtotal_row(\%totals, \@columns, \%column_alignment, \@subtotal_columns, 'listtotal'));
-
+  if ($params{want_binary_pdf}) {
+    $report->generate_with_headers();
+    return $report->generate_pdf_content(want_binary_pdf => 1);
+  }
   setup_oe_orders_action_bar();
   $report->generate_with_headers();
 
@@ -2062,6 +2065,7 @@ sub oe_prepare_xyz_from_order {
 
   my $order = SL::DB::Order->new(id => $::form->{id})->load;
   $order->flatten_to_form($::form, format_amounts => 1);
+  $::form->{taxincluded_changed_by_user} = 1;
 
   # hack: add partsgroup for first row if it does not exists,
   # because _remove_billed_or_delivered_rows and _remove_full_delivered_rows
index a7f6ede..f6db5e2 100644 (file)
@@ -1139,13 +1139,11 @@ sub send_email {
 
   RP->aging(\%myconfig, \%$form);
 
-  $form->{"statement_1"} = 1;
 
   my $email_form  = delete $form->{email_form};
   my %field_names = (to => 'email');
 
   $form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
-
   $form->{media} = 'email';
   print_form();
 
index 8c5e5f2..2e812f3 100644 (file)
@@ -39,6 +39,7 @@ use SL::Form;
 use SL::User;
 
 use SL::AM;
+use SL::CVar;
 use SL::CT;
 use SL::IC;
 use SL::WH;
@@ -765,6 +766,9 @@ sub report {
 
   show_no_warehouses_error() if (!scalar @{ $form->{WAREHOUSES} });
 
+  my $CVAR_CONFIGS = SL::DB::Manager::CustomVariableConfig->get_all_sorted(where => [ module => 'IC' ]);
+  my $INCLUDABLE_CVAR_CONFIGS = [ grep { $_->includeable } @{ $CVAR_CONFIGS } ];
+
   $form->{title}   = $locale->text("Report about warehouse contents");
 
   setup_wh_report_action_bar();
@@ -772,7 +776,10 @@ sub report {
   $form->header();
   print $form->parse_html_template("wh/report_filter",
                                    { "WAREHOUSES" => $form->{WAREHOUSES},
-                                     "UNITS"      => AM->unit_select_data(AM->retrieve_units(\%myconfig, $form)) });
+                                     "UNITS"      => AM->unit_select_data(AM->retrieve_units(\%myconfig, $form)),
+                                     # "CVAR_CONFIGS"            => $CVAR_CONFIGS, # nyi searchable cvars
+                                     "INCLUDABLE_CVAR_CONFIGS" => $INCLUDABLE_CVAR_CONFIGS,
+                                   });
 
   $main::lxdebug->leave_sub();
 }
@@ -786,6 +793,8 @@ sub generate_report {
   my %myconfig = %main::myconfig;
   my $locale   = $main::locale;
 
+  my $cvar_configs = CVar->get_configs('module' => 'IC');
+
   $form->{title}   = $locale->text("Report about warehouse contents");
   $form->{sort}  ||= 'partnumber';
   my $sort_col     = $form->{sort};
@@ -861,6 +870,9 @@ sub generate_report {
 
   my $report = SL::ReportGenerator->new(\%myconfig, $form);
 
+  my @includeable_custom_variables = grep { $_->{includeable} } @{ $cvar_configs };
+  push @columns, map { "cvar_$_->{name}" } @includeable_custom_variables;
+
   my @hidden_variables = map { "l_${_}" } @columns;
   push @hidden_variables, qw(warehouse_id bin_id partnumber partstypes_id description chargenumber bestbefore qty_op qty qty_unit partunit l_warehousedescription l_bindescription);
   push @hidden_variables, qw(include_empty_bins subtotal include_invalid_warehouses date);
@@ -883,6 +895,8 @@ sub generate_report {
 
   my $href = build_std_url('action=generate_report', grep { $form->{$_} } @hidden_variables);
   $href .= "&maxrows=".$form->{maxrows};
+  my %column_defs_cvars            = map { +"cvar_$_->{name}" => { 'text' => $_->{description} } } @includeable_custom_variables;
+  %column_defs = (%column_defs, %column_defs_cvars);
 
   map { $column_defs{$_}->{link} = $href . "&page=".$page."&sort=${_}&order=" . Q($_ eq $sort_col ? 1 - $form->{order} : $form->{order}) } @columns;
 
@@ -907,6 +921,11 @@ sub generate_report {
                        'attachment_basename'  => strftime($locale->text('warehouse_report_list') . '_%Y%m%d', localtime time));
   $report->set_options_from_form();
   $locale->set_numberformat_wo_thousands_separator(\%myconfig) if lc($report->{options}->{output_format}) eq 'csv';
+  CVar->add_custom_variables_to_report('module'         => 'IC',
+                                       'trans_id_field' => 'parts_id',
+                                       'configs'        => $cvar_configs,
+                                       'column_defs'    => \%column_defs,
+                                       'data'           => \@contents);
 
   my $all_units = AM->retrieve_units(\%myconfig, $form);
   my $idx       = 0;
index f3b2839..ee47ba4 100644 (file)
@@ -4,6 +4,11 @@
 
 20??-??-?? - Release ?.?.?
 
+Größere neue Features:
+  - Modul zur Zeiterfassung. Es ist nun möglich, auftrags-, kunden- oder
+    projektbezogen, Arbeitszeiten zu erfassen. Die erfassten Zeiten können
+    über einen Hintergrund-Job in Lieferscheine umgewandelt werden.
+
 Mittelgroße neue Features:
 
  - Der Import von Bankauszügen im MT940-Format wurde komplett neu
@@ -31,7 +36,38 @@ Mittelgroße neue Features:
      Mahnung
 
 Kleinere neue Features und Detailverbesserungen:
-
+ - Ausgelagerte Lieferscheinen können zurückgelagerte werden insofern der
+   konfigurierbare Zurücklagerungszeitraum noch nicht überschritten ist.
+ - Angebote und Aufträge im Ein- und Verkauf können optionale Positionen enthalten.
+   Optionale Positionen werden in der zweiten Zeile der Position aktiviert.
+   Die einzelne Position wird dann berechnet und erscheint im Ausdruck mit dem
+   berechnetem Preis, die Position wird aber nicht in der Gesamtsumme des Belegs
+   aufgenommen. Dies gilt auch für die Gesamt-Marge und den Gesamt-Ertrag des Belegs.
+   Innerhalb der Druckvorlagen steht das Attribut mit <%optional%> als Variable zu Verfügung.
+   Beim Status setzen eines Auftrags (offen oder geschlossen) werden optionale Position
+   ignoriert. D.h. ein Auftrag gilt als geschlossen, wenn alle nicht optionalen
+   Positionen fakturiert worden sind. Das Attribut optional steht auch nur in
+   den Angeboten/Aufträgen zu Verfügung. Sobald über den Workflow ein neuer Beleg
+   erstellt wird, wird die vorher optionale Position zu einer normalen Position
+   und wird dann auch entsprechend bei dem Rechnungsbeleg mit fakturiert und im
+   Druckvorlagen-System entfällt das Attribut <%optional%>.
+   Entsprechend exemplarisch im aktuellen Druckvorlagensatz RB ergänzt.
+
+ - Lagerbestandsbericht: Die Resultate pro Seite können im Bericht eingestellt werden
+ - Es gibt eine PDF-Druckvorschau für die Standard-Druckvorlage bei Angeboten und
+   Aufträgen im Einkauf und Verkauf ohne ein vorheriges Dialogmenü (Druckvorlage
+   ist die Standard-Druckvorlage und Typ immer 'PDF'). Die Druckvorschau wird nicht
+   im DMS oder WebDAV archiviert, es werden aber die Pflichtfelder des Belegs überprüft.
+ - Die benutzerdefinierten Variablen für Artikel können konfigurierbar im Tab Basisdaten
+   angezeigt werden (ohne extra Klick auf einen weiteren Tab)
+ - Der Lagerbestandsbericht wurde um die Anzeige von benutzerdefinierten Variablen
+   aus dem Bereich Artikel erweitert
+ - Im Lagerjournal ist standardmäßig die Berichtsanzeige um Dokument angehakt.
+   Sollte eine Warenbewegung durch einen Lieferschein oder eine Rechnung ausgelöst
+   worden sein, wird dies jetzt direkt verlinkt dort angezeigt
+ - Projekte wurden um Dateianhänge erweitert, die dort hochgeladenen Dokumente
+   stehen beim E-Mail-Versand in allen verknüpften Belegen vorausgewählt zu
+   Verfügung
  - Dateimanagement: In der Liste der Dateien werden Vorschaubilder angezeigt,
    falls möglich. Diese werden beim Drüberfahren vergrößert.
  - Dateimanagement: Dokumente können auch hochgeladen werden, dort, wo sie
index aba6821..6a566c4 100644 (file)
@@ -93,6 +93,9 @@ namespace('kivi', function(k){
         data['filter.valid'] = 'valid'; // default
       }
 
+      if (o.description_style)
+        data['description_style'] = o.description_style;
+
       return data;
     }
 
index 8645a09..6b4ea46 100644 (file)
@@ -303,7 +303,9 @@ namespace('kivi.File', function(ns) {
 
   ns.add_enlarged_thumbnail = function(e) {
     var file_id        = $(e.target).data('file-id');
+    var file_version   = $(e.target).data('file-version');
     var overlay_img_id = 'enlarged_thumb_' + file_id;
+    if (file_version) { overlay_img_id = overlay_img_id + '_' + file_version };
     var overlay_img    = $('#' + overlay_img_id);
 
     if (overlay_img.data('is-overlay-shown') == 1) return;
@@ -317,7 +319,7 @@ namespace('kivi.File', function(ns) {
     var data = {
       action:         'File/ajax_get_thumbnail',
       file_id:        file_id,
-      file_version:   $(e.target).data('file-version'),
+      file_version:   file_version,
       size:           512
     };
 
index 714a40e..5459c0b 100644 (file)
@@ -63,9 +63,10 @@ namespace('kivi.Order', function(ns) {
     $.post("controller.pl", data, kivi.eval_json_result);
   };
 
-  ns.show_print_options = function(warn_on_duplicates) {
+  ns.show_print_options = function(warn_on_duplicates, warn_on_reqdate) {
     if (!ns.check_cv()) return;
     if (warn_on_duplicates && !ns.check_duplicate_parts(kivi.t8("Do you really want to print?"))) return;
+    if (warn_on_reqdate    && !ns.check_valid_reqdate())   return;
 
     kivi.popup_dialog({
       id: 'print_options',
index 2b63a1a..98a538c 100644 (file)
@@ -286,6 +286,7 @@ namespace('kivi.SalesPurchase', function(ns) {
       type:         $('#type').val(),
       vc:           vc,
       vc_id:        $('#' + vc + '_id').val(),
+      project_id:  $('#globalproject_id').val(),
     };
 
     $('[name^=id_],[name^=partnumber_]').each(function(idx, elt) {
index 21ac487..467fcbd 100644 (file)
@@ -8,6 +8,35 @@ namespace('kivi.ShopOrder', function(ns) {
     });
   };
 
+  ns.get_orders_one = function() {
+
+    var data = $('#get_one_order_form').serializeArray();
+    data.push({ name: 'type', value: 'get_one'});
+    data.push({ name: 'action', value: 'ShopOrder/get_orders' });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.get_orders_next = function() {
+
+    $.post("controller.pl", { action: 'ShopOrder/get_orders', type: 'get_next'}, kivi.eval_json_result);
+  };
+
+  ns.getOneOrderInitialize = function() {
+    kivi.popup_dialog({
+      id: 'get_one',
+      dialog: {
+        title: kivi.t8('Get one shoporder'),
+      }
+    });
+  };
+
+
+  ns.get_one_order_setup = function() {
+    kivi.ShopOrder.getOneOrderInitialize();
+    kivi.submit_ajax_form('controller.pl?action=ShopOrder/get_orders', $('#shoporder'));
+  };
+
   ns.massTransferStarted = function() {
     $('#status_mass_transfer').data('timerId', setInterval(function() {
       $.get("controller.pl", {
diff --git a/js/kivi.TimeRecording.js b/js/kivi.TimeRecording.js
new file mode 100644 (file)
index 0000000..47b0f16
--- /dev/null
@@ -0,0 +1,59 @@
+namespace('kivi.TimeRecording', function(ns) {
+  'use strict';
+
+  ns.set_end_date = function() {
+    if ($('#start_date').val() !== '' && $('#end_date').val() === '') {
+      var kivi_start_date  = kivi.format_date(kivi.parse_date($('#start_date').val()));
+      $('#end_date').val(kivi_start_date);
+    }
+  };
+
+  ns.set_current_date_time = function(what) {
+    if (what !== 'start' && what !== 'end') return;
+
+    var $date = $('#' + what + '_date');
+    var $time = $('#' + what + '_time');
+    var date = new Date();
+
+    $date.val(kivi.format_date(date));
+    $time.val(kivi.format_time(date));
+  };
+
+  ns.order_changed = function(value) {
+    if (!value) {
+      $('#time_recording_customer_id').data('customer_vendor_picker').set_item({});
+      $('#time_recording_customer_id_name').prop('disabled', false);
+      $('#time_recording_project_id').data('project_picker').set_item({});
+      $('#time_recording_project_id_name').prop('disabled', false);
+      return;
+    }
+
+    var url = 'controller.pl?action=TimeRecording/ajaj_get_order_info&id='+ value;
+    $.getJSON(url, function(data) {
+      $('#time_recording_customer_id').data('customer_vendor_picker').set_item(data.customer);
+      $('#time_recording_customer_id_name').prop('disabled', true);
+      $('#time_recording_project_id').data('project_picker').set_item(data.project);
+      $('#time_recording_project_id_name').prop('disabled', true);
+    });
+  };
+
+  ns.project_changed = function() {
+    var project_id = $('#time_recording_project_id').val();
+
+    if (!project_id) {
+      $('#time_recording_customer_id_name').prop('disabled', false);
+      return;
+    }
+
+    var url = 'controller.pl?action=TimeRecording/ajaj_get_project_info&id='+ project_id;
+    $.getJSON(url, function(data) {
+      if (data) {
+        $('#time_recording_customer_id').data('customer_vendor_picker').set_item(data.customer);
+        $('#time_recording_customer_id_name').prop('disabled', true);
+      } else {
+        $('#time_recording_customer_id_name').prop('disabled', false);
+      }
+    });
+  };
+
+});
index bc19ee5..b564ffb 100644 (file)
@@ -5,11 +5,14 @@ namespace("kivi.Validator", function(ns) {
   // 'selector'. Elements that should be validated must have an
   // attribute named "data-validate" which is set to a space-separated
   // list of tests to perform. Additionally, the attribute
-  // "data-title" must be set to a human-readable name of the field
-  // that can be shown as part of an error message.
+  // "data-title" can be set to a human-readable name of the field
+  // that can be shown in front of an error message.
   //
   // Supported validation tests are:
   // - "required": the field must be set (its .val() must not be empty)
+  // - "number": the field must be in number format (its .val() must in the right format)
+  // - "date": the field must be in date format (its .val() must in the right format)
+  // - "time": the field must be in time format (its .val() must in the right format)
   //
   // The validation will abort and return "false" as soon as
   // validation routine fails.
@@ -30,6 +33,12 @@ namespace("kivi.Validator", function(ns) {
   };
 
   ns.validate = function($e) {
+    var $e_annotate;
+    if ($e.data('ckeditorInstance')) {
+      $e_annotate = $($e.data('ckeditorInstance').editable().$);
+      if ($e.data('title'))
+        $e_annotate.data('title', $e.data('title'));
+    }
     var tests = $e.data('validate').split(/ +/);
 
     for (var test_idx in tests) {
@@ -38,7 +47,7 @@ namespace("kivi.Validator", function(ns) {
         continue;
 
       if (ns.checks[test]) {
-        if (!ns.checks[test]($e))
+        if (!ns.checks[test]($e, $e_annotate))
           return false;
       } else {
         var error = "kivi.validate_form: unknown test '" + test + "' for element ID '" + $e.prop('id') + "'";
@@ -52,77 +61,85 @@ namespace("kivi.Validator", function(ns) {
   }
 
   ns.checks = {
-    required: function($e) {
+    required: function($e, $e_annotate) {
+      $e_annotate = $e_annotate || $e;
+
       if ($e.val() === '') {
-        ns.annotate($e, kivi.t8("This field must not be empty."));
+        ns.annotate($e_annotate, kivi.t8("This field must not be empty."));
         return false;
       } else {
-        ns.annotate($e);
+        ns.annotate($e_annotate);
         return true;
       }
     },
-    number: function($e) {
+    number: function($e, $e_annotate) {
+      $e_annotate = $e_annotate || $e;
+
       var number_string = $e.val();
 
       var parsed_number = kivi.parse_amount(number_string);
 
       if (parsed_number === null) {
         $e.val('');
-        ns.annotate($e);
+        ns.annotate($e_annotate);
         return true;
       } else
       if (parsed_number === undefined) {
-        ns.annotate($e, kivi.t8('Wrong number format (#1)', [ kivi.myconfig.numberformat ]));
+        ns.annotate($e_annotate, kivi.t8('Wrong number format (#1)', [ kivi.myconfig.numberformat ]));
         return false;
       } else
       {
         var formatted_number = kivi.format_amount(parsed_number);
         if (formatted_number != number_string)
           $e.val(formatted_number);
-        ns.annotate($e);
+        ns.annotate($e_annotate);
         return true;
       }
     },
-    date: function($e) {
+    date: function($e, $e_annotate) {
+      $e_annotate = $e_annotate || $e;
+
       var date_string = $e.val();
 
       var parsed_date = kivi.parse_date(date_string);
 
       if (parsed_date === null) {
         $e.val('');
-        ns.annotate($e);
+        ns.annotate($e_annotate);
         return true;
       } else
       if (parsed_date === undefined) {
-        ns.annotate($e, kivi.t8('Wrong date format (#1)', [ kivi.myconfig.dateformat ]));
+        ns.annotate($e_annotate, kivi.t8('Wrong date format (#1)', [ kivi.myconfig.dateformat ]));
         return false;
       } else
       {
         var formatted_date = kivi.format_date(parsed_date);
         if (formatted_date != date_string)
           $e.val(formatted_date);
-        ns.annotate($e);
+        ns.annotate($e_annotate);
         return true;
       }
     },
-    time: function($e) {
+    time: function($e, $e_annotate) {
+      $e_annotate = $e_annotate || $e;
+
       var time_string = $e.val();
 
       var parsed_time = kivi.parse_time(time_string);
       if (parsed_time === null) {
         $e.val('');
-        ns.annotate($e);
+        ns.annotate($e_annotate);
         return true;
       } else
       if (parsed_time === undefined) {
-        ns.annotate($e, kivi.t8('Wrong time format (#1)', [ kivi.myconfig.timeformat ]));
+        ns.annotate($e_annotate, kivi.t8('Wrong time format (#1)', [ kivi.myconfig.timeformat ]));
         return false;
       } else
       {
         var formatted_time = kivi.format_time(parsed_time);
         if (formatted_time != time_string)
           $e.val(formatted_time);
-        ns.annotate($e);
+        ns.annotate($e_annotate);
         return true;
       }
     }
@@ -134,6 +151,9 @@ namespace("kivi.Validator", function(ns) {
       if ($e.hasClass('tooltipstered'))
         $e.tooltipster('destroy');
 
+      if ($e.data('title'))
+        error = $e.data('title') + ': ' + error;
+
       $e.tooltipster({
         content: error,
         theme: 'tooltipster-light',
index 9b0153d..0a77669 100644 (file)
@@ -63,6 +63,7 @@ namespace("kivi").setupLocale({
 "File upload":"Datei Upload",
 "Function block actions":"Funktionsblockaktionen",
 "Generate and print sales delivery orders":"Erzeuge und drucke Lieferscheine",
+"Get one shoporder":"Hole eine Bestellung",
 "Hide all details":"Alle Details verbergen",
 "Hide details":"Details verbergen",
 "History":"Historie",
index a089e68..2d3885a 100644 (file)
@@ -63,6 +63,7 @@ namespace("kivi").setupLocale({
 "File upload":"",
 "Function block actions":"",
 "Generate and print sales delivery orders":"",
+"Get one shoporder":"",
 "Hide all details":"",
 "Hide details":"",
 "History":"",
index 66440a0..e4c39b9 100755 (executable)
@@ -241,6 +241,7 @@ $self->{texts} = {
   'Add sub function block'      => 'Unterfunktionsblock hinzufügen',
   'Add taxzone'                 => 'Steuerzone hinzufügen',
   'Add text block'              => 'Textblock erfassen',
+  'Add time recording article'  => 'Artikel für Zeiterfassung erfassen',
   'Add title'                   => 'Titel hinzufügen',
   'Add unit'                    => 'Einheit hinzufügen',
   'Added sections and function blocks: #1' => 'Hinzugefügte Abschnitte und Funktionsblöcke: #1',
@@ -484,6 +485,7 @@ $self->{texts} = {
   'Bis Konto: '                 => 'bis Konto: ',
   'Body'                        => 'Text',
   'Body:'                       => 'Text:',
+  'Booked'                      => 'gebucht',
   'Booking group'               => 'Buchungsgruppe',
   'Booking group #1 needs a valid expense account' => 'Buchungsgruppe #1 braucht ein gültiges Aufwandskonto',
   'Booking group #1 needs a valid income account' => 'Buchungsgruppe #1 braucht ein gültiges Erfolgskonto',
@@ -588,6 +590,7 @@ $self->{texts} = {
   'Cannot transfer negative entries.' => 'Kann keine negativen Mengen auslagern.',
   'Cannot transfer negative quantities.' => 'Negative Mengen können nicht ausgelagert werden.',
   'Cannot transfer. <br> Reason:<br>#1' => 'Kann nicht ein-/auslagern. <br>Grund:<br>#1',
+  'Cannot undo delivery order transfer!' => 'Kann Lagerbewegung des Lieferscheins nicht zurücklagern!',
   'Cannot unlink payment for a closed period!' => 'Ein oder alle Bankbewegungen befinden sich innerhalb einer geschloßenen Periode. ',
   'Carry over account for year-end closing' => 'Saldenvortragskonto',
   'Carry over shipping address' => 'Lieferadresse übernehmen',
@@ -792,6 +795,7 @@ $self->{texts} = {
   'Create with profile \'Factur-X 1.0.05/ZUGFeRD 2.1.1 extended\' (test mode)' => 'Mit Profil »Factur-X 1.0.05/ZUGFeRD 2.1.1 extended« (Test-Modus)',
   'Create with profile \'XRechnung 2.0.0\'' => 'Mit Profil »XRechnung 2.0.0«',
   'Create with profile \'XRechnung 2.0.0\' (test mode)' => 'Mit Profil »XRechnung 2.0.0« (Test-Modus)',
+  'Create, edit and list time recordings' => 'Zeiterfassungen erfassen, bearbeiten und ansehen',
   'Created by'                  => 'Erstellt von',
   'Created for'                 => 'Erstellt für',
   'Created on'                  => 'Erstellt am',
@@ -856,6 +860,7 @@ $self->{texts} = {
   'Customer deleted!'           => 'Kunde gelöscht!',
   'Customer details'            => 'Kundendetails',
   'Customer missing!'           => 'Kundenname fehlt!',
+  'Customer must not be empty.' => 'Kunden darf nicht leer sein.',
   'Customer not found'          => 'Kunde nicht gefunden',
   'Customer saved'              => 'Kunde gespeichert',
   'Customer saved!'             => 'Kunde gespeichert!',
@@ -910,6 +915,7 @@ $self->{texts} = {
   'Database Management'         => 'Datenbankadministration',
   'Database Superuser'          => 'Datenbank-Super-Benutzer',
   'Database User'               => 'Datenbankbenutzer',
+  'Database errors: #1'         => 'Datenbankfehler: #1',
   'Database host and port'      => 'Datenbankhost und -port',
   'Database login (#1)'         => 'Datenbankanmeldung (#1)',
   'Database name'               => 'Datenbankname',
@@ -978,6 +984,7 @@ $self->{texts} = {
   'Default transport article number' => 'Standard Versand / Transport-Erinnerungs-Artikel',
   'Default unit'                => 'Standardeinheit',
   'Default value'               => 'Standardwert',
+  'Defines the interval where undoing transfers from a delivery order are allowed.' => 'Zeitintervall in Tagen, an denen ein Zurücklagern der Lagerbewegung innerhalb eines Lieferscheins möglich ist.',
   'Delete'                      => 'Löschen',
   'Delete Account'              => 'Konto löschen',
   'Delete Attachments'          => 'Anhänge löschen',
@@ -1035,6 +1042,7 @@ $self->{texts} = {
   '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.',
   'Description of #1'           => 'Beschreibung von #1',
   'Design custom data export queries' => 'Benutzerdefinierte Datenexport-Abfragen designen',
   'Destination BIC'             => 'Ziel-BIC',
@@ -1058,6 +1066,7 @@ $self->{texts} = {
   'Discounts'                   => 'Rabatte',
   'Display'                     => 'Anzeigen',
   'Display file'                => 'Datei anzeigen',
+  'Display in basic data tab'   => 'Im Reiter Basisdaten anzeigen',
   'Display options'             => 'Anzeigeoptionen',
   'Displayable Name Preferences' => 'Einstellungen für Anzeigenamen',
   'Do not change the tax rate of taxkey 0.' => 'Ändern Sie nicht den Steuersatz vom Steuerschlüssel 0.',
@@ -1153,6 +1162,7 @@ $self->{texts} = {
   'Duplicate'                   => 'Duplikat',
   'Duplicate in CSV file'       => 'Duplikat in CSV-Datei',
   'Duplicate in database'       => 'Duplikat in Datenbank',
+  'Duration'                    => 'Dauer',
   'During the next update a taxkey 0 with tax rate of 0 will automatically created.' => 'Beim nächsten Ausführen des Updates wird ein Steuerschlüssel 0 mit einem Steuersatz von 0% automatisch erzeugt.',
   'E Mail'                      => 'E-Mail',
   'E-Mail'                      => 'E-Mail',
@@ -1267,6 +1277,8 @@ $self->{texts} = {
   'Edit the request_quotation'  => 'Bearbeiten der Preisanfrage',
   'Edit the sales_order'        => 'Bearbeiten des Auftrags',
   'Edit the sales_quotation'    => 'Bearbeiten des Angebots',
+  'Edit time recording article' => 'Artikel für Zeiterfassung bearbeiten',
+  'Edit time recordings of all staff members' => 'Zeiterfassungseinträge aller Mitarbeiter bearbeiten',
   'Edit title'                  => 'Titiel bearbeiten',
   'Edit units'                  => 'Einheiten bearbeiten',
   'Edit user signature'         => 'Benutzersignatur bearbeiten',
@@ -1281,12 +1293,14 @@ $self->{texts} = {
   'Employee #1 saved!'          => 'Benutzer #1 gespeichert!',
   'Employee (database ID)'      => 'Bearbeiter (Datenbank-ID)',
   'Employee from the original invoice' => 'Mitarbeiter der Ursprungs-Rechnung',
+  'Employee must not be empty.' => 'Bearbeiter darf nicht leer sein.',
   'Employees'                   => 'Benutzer',
   'Employees with read access to the project\'s invoices' => 'Angestellte mit Leserechten auf die Projektrechnungen',
   'Empty selection for warehouse will not be added, even if the old bin is still visible (use back and forth to edit again).' => 'Leere Lager-Auswahl wird ignoriert, selbst wenn noch ein Lagerplatz ausgewählt ist. Alle Daten können durch zurück und vorwärts korrigiert werden.',
   'Empty transaction!'          => 'Buchung ist leer!',
   'Enabled Quick Searched'      => 'Aktivierte Schnellsuchen',
   'Enabled modules'             => 'Aktivierte Module',
+  'End'                         => 'Ende',
   'End date'                    => 'Enddatum',
   'Enter longdescription'       => 'Langtext eingeben',
   'Enter the requested execution date or leave empty for the quickest possible execution:' => 'Geben Sie das jeweils gewünschte Ausführungsdatum an, oder lassen Sie das Feld leer für die schnellstmögliche Ausführung:',
@@ -1294,6 +1308,7 @@ $self->{texts} = {
   'Entries for which automatic conversion succeeded:' => 'Einträge, für die die automatische Umstellung erfolgreich war:',
   'Entries ready to import'     => 'Zu importierende Einträge',
   'Entries with errors'         => 'Einträge mit Fehlern',
+  'Entry overlaps with "#1".'   => 'Einträg überlappt sich mit "#1"',
   'Equity'                      => 'Passiva',
   'Erfolgsrechnung'             => 'Erfolgsrechnung',
   'Error'                       => 'Fehler',
@@ -1457,6 +1472,7 @@ $self->{texts} = {
   'Feb'                         => 'Feb',
   'February'                    => 'Februar',
   'Fee'                         => 'Gebühr',
+  'Fetch order'                 => 'Hole Bestellung',
   'Field'                       => 'Feld',
   'File'                        => 'Datei',
   'File \'#1\' is used as new Version !' => 'Datei \'#1\' wird als neue Version verwendet!',
@@ -1470,6 +1486,7 @@ $self->{texts} = {
   'Files'                       => 'Dateien',
   'Files from customer'         => 'Kundendateien',
   'Files from parts'            => 'Artikeldateien',
+  'Files from projects'         => 'Projektdateien',
   'Files from vendor'           => 'Lieferantendateien',
   'Filter'                      => 'Filter',
   'Filter by Partsgroups'       => 'Nach Warengruppen filtern',
@@ -1563,6 +1580,9 @@ $self->{texts} = {
   'General settings'            => 'Allgemeine Einstellungen',
   'Generate and print sales delivery orders' => 'Erzeuge und drucke Lieferscheine',
   'Germany'                     => 'Deutschland',
+  'Get one order'               => 'Hole eine Bestellung',
+  'Get one order by shopordernumber' => 'Hole eine Bestellung über Shopbestellnummer',
+  'Get one shoporder'           => 'Hole eine Bestellung',
   'Get shoporders'              => 'Shopbestellungen holen und bearbeiten',
   'Git revision: #1, #2 #3'     => 'Git-Revision: #1, #2 #3',
   'Given Name'                  => 'Vorname',
@@ -1893,6 +1913,7 @@ $self->{texts} = {
   'List of jobs'                => 'Jobliste',
   'List of tax zones'           => 'Liste der Steuerzonen',
   'List open SEPA exports'      => 'Noch nicht ausgeführte SEPA-Exporte anzeigen',
+  'List time recordings of all staff members' => 'Zeiterfassungseinträge aller Mitarbeiter anzeigen',
   'Listprice'                   => 'Listenpreis',
   'Load'                        => 'Laden',
   'Load an existing draft'      => 'Einen bestehenden Entwurf laden',
@@ -2135,6 +2156,7 @@ $self->{texts} = {
   'No template has been selected yet.' => 'Es wurde noch keine Vorlage ausgewählt.',
   'No text blocks have been created for this position.' => 'Für diese Position wurden noch keine Textblöcke angelegt.',
   'No text has been entered yet.' => 'Es wurde noch kein Text eingegeben.',
+  'No time recordings to convert' => 'Es sind keine Zeiterfassungseinträge zu konvertieren',
   'No title yet'                => 'Bisher ohne Titel',
   'No transaction on chart bank chosen!' => 'Keine Buchung auf Bankkonto gewählt.',
   'No transaction selected!'    => 'Keine Transaktion ausgewählt',
@@ -2185,8 +2207,8 @@ $self->{texts} = {
   'Number of copies'            => 'Anzahl Kopien',
   'Number of data sets'         => 'Anzahl Datensätze',
   'Number of data uploaded:'    => 'Uploaded Datensätze',
-  'Number of deliveryorders created:' => 'Anzahl erzeugter Lieferscheine:',
-  'Number of deliveryorders printed:' => 'Anzahl gedruckter Lieferscheine:',
+  'Number of delivery orders created:' => 'Anzahl erzeugter Lieferscheine:',
+  'Number of delivery orders printed:' => 'Anzahl gedruckter Lieferscheine:',
   'Number of entries changed: #1' => 'Anzahl geänderter Einträge: #1',
   'Number of invoices'          => 'Anzahl Rechnungen',
   'Number of invoices created:' => 'Anzahl erstellter Rechnungen:',
@@ -2239,6 +2261,7 @@ $self->{texts} = {
   'OpenDocument/OASIS'          => 'OpenDocument/OASIS',
   'Openings'                    => 'Öffnungszeiten',
   'Option'                      => 'Option',
+  'Optional'                    => 'Optional',
   'Optional comment'            => 'Optionaler Kommentar',
   'Options'                     => 'Optionen',
   'Or download the whole Installation Documentation as PDF (350kB) for off-line study (currently in German Language): ' => 'Oder laden Sie die komplette Installationsbeschreibung als PDF (350kB) herunter: ',
@@ -2296,6 +2319,7 @@ $self->{texts} = {
   'PLZ Grosskunden'             => 'PLZ Grosskunden',
   'POSTED'                      => 'Gebucht',
   'POSTED AS NEW'               => 'Als neu gebucht',
+  'PREVIEWED'                   => 'Druckvorschau',
   'PRINTED'                     => 'Gedruckt',
   'Package name'                => 'Paketname',
   'Packing Lists'               => 'Lieferschein',
@@ -2552,6 +2576,7 @@ $self->{texts} = {
   'Project (description)'       => 'Projekt (Beschreibung)',
   'Project (number)'            => 'Projektnummer',
   'Project Description'         => 'Projektbeschreibung',
+  'Project Details'             => 'Projektdetails',
   'Project Link'                => 'Projektverknüpfung',
   'Project Number'              => 'Projektnummer',
   'Project Numbers'             => 'Projektnummern',
@@ -2752,6 +2777,7 @@ $self->{texts} = {
   'Reset'                       => 'Zurücksetzen',
   'Result'                      => 'Ergebnis',
   'Result of SQL query'         => 'Ergebnis einer SQL-Abfrage',
+  'Results per page'            => 'Treffer pro Seite',
   'Revenue'                     => 'Erlöskonto',
   'Revenue Account'             => 'Erlöskonto',
   'Reversal invoices cannot be canceled.' => 'Stornorechnungen können nicht storniert werden.',
@@ -2858,6 +2884,7 @@ $self->{texts} = {
   'Save and close'              => 'Speichern und schließen',
   'Save and execute'            => 'Speichern und ausführen',
   'Save and keep open'          => 'Speichern und geöffnet lassen',
+  'Save and preview PDF'        => 'PDF-Druckvorschau',
   'Save and print'              => 'Speichern und drucken',
   'Save as a new draft.'        => 'Als neuen Entwurf speichern',
   'Save as new'                 => 'Als neu speichern',
@@ -2870,6 +2897,7 @@ $self->{texts} = {
   'Saving failed. Error message from the database: #1' => 'Speichern schlug fehl. Fehlermeldung der Datenbank: #1',
   'Saving the file \'%s\' failed. OS error message: %s' => 'Das Speichern der Datei \'%s\' schlug fehl. Fehlermeldung des Betriebssystems: %s',
   'Saving the record template \'#1\' failed.' => 'Das Speichern der Belegvorlage »#1« schlug fehl.',
+  'Saving the time recording entry failed: #1' => 'Speichern des Zeiterfassung-Eintrags schlug fehl: #1',
   'Score'                       => 'Punkte',
   'Screen'                      => 'Bildschirm',
   'Scrollbar height percentage for form postion area (0 means no scrollbar)' => 'Prozentuale Höhe des Scrollbereichs der Positionen in Belegen (0 bedeutet kein Scrollbar)',
@@ -2981,6 +3009,7 @@ $self->{texts} = {
   'Shop Orders'                 => 'Shopaufträge',
   'Shop article'                => 'Shopartikel',
   'Shop customernumber'         => 'Shop - Kundennumer',
+  'Shop or ordernumber not selected.' => 'Shop oder Bestellnummer nicht ausgewählt',
   'Shop orderdate'              => 'Shopauftragsdatum',
   'Shop ordernumber'            => 'Shopauftragsnummer',
   'Shop part'                   => 'Shopartikel',
@@ -2990,6 +3019,7 @@ $self->{texts} = {
   'Shopcategories'              => 'Shopartikelgruppen',
   'Shopimages - valid for all shops' => 'Shopbilder Gültig für alle Shops',
   'Shoporder'                   => 'Shopbestellung',
+  'Shoporder "#2" From Shop "#1" is already fetched' => 'Shopbestellung #1 von Shop #2 wurde schon geholt',
   'Shoporder deleted -- '       => 'ungültig',
   'Shoporder not found'         => 'Shopbestellung nicht gefunden',
   'Shoporderlock'               => 'Shopauftragssperre',
@@ -3100,12 +3130,16 @@ $self->{texts} = {
   'Space'                       => 'Leerzeichen',
   'Split entry detected. The values you have entered will result in an entry with more than one position on both debit and credit. Due to known problems involving accounting software kivitendo does not allow these.' => 'Splitbuchung! Die eingebenen Werte würden eine Buchung auslösen, die jeweils mehr als eine Position auf Soll und Haben hätte. Um Kompatibilität mit DATEV zu gewährleisten erlaubt kivitendo keine Splitbuchungen.',
   'Spoolfile'                   => 'Druckdatei',
+  'Staff member must not be empty.' => 'Mitarbeiter darf nicht leer sein.',
+  'Start'                       => 'Start',
   'Start (verb)'                => 'Starten',
   'Start Dunning Process'       => 'Mahnprozess starten',
   'Start date'                  => 'Startdatum',
   'Start of year'               => 'Jahresanfang',
   'Start process'               => 'Prozess starten',
   'Start the correction assistant' => 'Korrekturassistenten starten',
+  'Start time'                  => 'Startzeit',
+  'Start time must be earlier than end time.' => 'Startzeit muss vor der Endzeit liegen.',
   'Startdate method'            => 'Methode zur Ermittlung des Startdatums',
   'Startdate_coa'               => 'Gültig ab',
   'Starting Balance'            => 'Eröffnungsbilanzwerte',
@@ -3276,6 +3310,7 @@ $self->{texts} = {
   'The LDAP server "#1:#2" is unreachable. Please check config/kivitendo.conf.' => 'Der LDAP-Server "#1:#2" ist nicht erreichbar. Bitte überprüfen Sie die Angaben in config/kivitendo.conf.',
   'The Mail strings have been saved.' => 'Die vorbelegten E-Mail-Texte wurden gespeichert.',
   'The PDF has been created'    => 'Die PDF-Datei wurde erstellt.',
+  'The PDF has been previewed'  => 'PDF-Druckvorschau ausgeführt',
   'The PDF has been printed'    => 'Das PDF-Dokument wurde gedruckt.',
   'The SEPA export has been created.' => 'Der SEPA-Export wurde erstellt',
   'The SEPA strings have been saved.' => 'Die bei SEPA-Überweisungen verwendeten Begriffe wurden gespeichert.',
@@ -3412,6 +3447,7 @@ $self->{texts} = {
   'The following currencies have been used, but they are not defined:' => 'Die folgenden Währungen wurden benutzt, sind aber nicht ordnungsgemäß in der Datenbank eingetragen:',
   'The following delivery orders could not be processed because they are already closed: #1' => 'Die folgenden Lieferscheine konnten nicht verarbeitet werden, da sie bereits geschlossen sind: #1',
   'The following drafts have been saved and can be loaded.' => 'Die folgenden Entwürfe wurden gespeichert und können geladen werden.',
+  'The following errors occurred:' => 'Folgende Fehler sind aufgetreten:',
   'The following groups are valid for this client' => 'Die folgenden Gruppen sind für diesen Mandanten gültig',
   'The following is only a preview.' => 'Das Folgende ist nur eine Vorschau.',
   'The following list has been generated automatically from existing users collapsing users with identical settings into a single entry.' => 'Die folgende Liste wurde automatisch aus den im System vorhandenen Benutzern zusammengestellt, wobei identische Einstellungen zu einem Eintrag zusammengefasst wurden.',
@@ -3660,6 +3696,8 @@ $self->{texts} = {
   'This discount is only valid in purchase documents' => 'Dieser Rabatt ist nur in Einkaufsdokumenten gültig',
   'This discount is only valid in records with customer or vendor' => 'Dieser Rabatt ist nur in Dokumenten mit Kunde oder Lieferant gültig',
   'This discount is only valid in sales documents' => 'Dieser Rabatt ist nur in Verkaufsdokumenten gültig',
+  'This entry is using date and duration. This information will be overwritten on saving.' => 'Dieser Eintrag verwendet Datum und Dauer. Diese Information wird beim Speichern überschrieben.',
+  'This entry is using start and end time. This information will be overwritten on saving.' => 'Dieser Eintrag verwendet Start- und End-Zeit. Diese Information wird beim Speichern überschrieben.',
   'This export will include all records in the given time range and all supplicant information from checked entities. You will receive a single zip file. Please extract this file onto the data medium requested by your auditor.' => 'Dieser Export umfasst alle Belege im gewählten Zeitrahmen und die dazugehörgen Informationen aus den gewählten Blöcken. Sie erhalten eine einzelne Zip-Datei. Bitte entpacken Sie diese auf das Medium das Ihr Steuerprüfer wünscht.',
   'This feature especially prevents mistakes by mixing up prior tax and sales tax.' => 'Dieses Feature vermeidet insbesondere Verwechslungen von Umsatz- und Vorsteuer.',
   'This field must not be empty.' => 'Dieses Feld darf nicht leer sein.',
@@ -3722,6 +3760,9 @@ $self->{texts} = {
   'Threshold for warning on quantity difference' => 'Schwellenwert für Warnung bei Mengenabweichung',
   'Time'                        => 'Zeit',
   'Time Format'                 => 'Uhrzeitformat',
+  'Time Recording'              => 'Zeiterfassung',
+  'Time Recording Articles'     => 'Artikel für Zeiterfassung',
+  'Time Recordings'             => 'Zeiterfassung',
   'Time and price estimate'     => 'Zeit- und Preisschätzung',
   'Time estimate'               => 'Zeitschätzung',
   'Time period for the analysis:' => 'Analysezeitraum:',
@@ -3783,6 +3824,7 @@ $self->{texts} = {
   'Transfer To Stock'           => 'Lagereingang',
   'Transfer all marked'         => 'Markierte übernehmen',
   'Transfer data to Geierlein ELSTER application' => 'Daten in Geierlein ELSTER-Anwendung übernehmen',
+  'Transfer date exceeds the maximum allowed interval.' => 'Das Belegdatum ist älter als das maximale Zurücklagerungs-Intervall es zulässt.',
   'Transfer from warehouse'     => 'Quelllager',
   'Transfer in'                 => 'Einlagern',
   'Transfer in via default'     => 'Einlagern über Standard-Lagerplatz',
@@ -3794,6 +3836,7 @@ $self->{texts} = {
   'Transfer qty'                => 'Umlagermenge',
   'Transfer services via default' => 'Falls Ein- /Auslagern über Standardlagerplatz aktiviert ist, auch die Dienstleistungen standardmässig Ein- und Auslagern',
   'Transfer successful'         => 'Lagervorgang erfolgreich',
+  'Transfer undone.'            => 'Zurücklagerung erfolgreich',
   'Transferred'                 => 'Übernommen',
   'Translation'                 => 'Übersetzung',
   'Translations'                => 'Übersetzungen',
@@ -3812,6 +3855,7 @@ $self->{texts} = {
   'Type of Vendor'              => 'Lieferantentyp',
   'TypeAbbreviation'            => 'Typ-Abkürzung',
   'Types of Business'           => 'Kunden-/Lieferantentypen',
+  'UNDO TRANSFER'               => 'Zurücklagern',
   'UNIMPORT'                    => 'Import rückgängig',
   'USTVA'                       => 'USTVA',
   'USTVA 2004'                  => 'USTVA 2004',
@@ -3831,6 +3875,8 @@ $self->{texts} = {
   'Unbalanced Ledger'           => 'Bilanzfehler',
   'Unchecked custom variables will not appear in orders and invoices.' => 'Unmarkierte Variablen werden für diesen Artikel nicht in Aufträgen und Rechnungen angezeigt.',
   'Undo SEPA exports'           => 'SEPA-Exporte rückgängig machen',
+  'Undo Transfer'               => 'Zurücklagern',
+  'Undo Transfer Interval'      => 'Zurücklagerungs-Intervall',
   'Unfinished follow-ups'       => 'Nicht erledigte Wiedervorlagen',
   'Unfortunately you have no warehouse defined.' => 'Leider, gibt es kein Lager in diesem Mandanten.',
   'Unimport all'                => 'Alle zurück zur Quelle',
@@ -3907,6 +3953,7 @@ $self->{texts} = {
   'Use a text field to enter (new) contact titles if enabled. Otherwise, only a drop down box is offered.' => 'Textfeld zusätzlich zur Eingabe (neuer) Titel von Ansprechpersonen verwenden. Sonst wird nur eine Auswahlliste angezeigt.',
   'Use a text field to enter (new) greetings if enabled. Otherwise, only a drop down box is offered.' => 'Textfeld zusätzlich zur Eingabe (neuer) Anreden verwenden. Sonst wird nur eine Auswahlliste angezeigt.',
   'Use as new'                  => 'Als neu verwenden',
+  'Use date and duration for time recordings' => 'Datum und Dauer für Zeiterfassung verwenden',
   'Use default booking group because setting is \'all\'' => 'Standardbuchungsgruppe wird verwendet',
   'Use default booking group because wanted is missing' => 'Fehlende Buchungsgruppe, deshalb Standardbuchungsgruppe',
   'Use default warehouse for assembly transfer' => 'Zum Fertigen Standardlager des Bestandteils verwenden',
@@ -3945,6 +3992,7 @@ $self->{texts} = {
   'VAT ID and/or taxnumber must be given.' => 'UStId und/oder Steuernummer muss angegeben werden.',
   'VN'                          => 'Kred.-Nr.',
   'Valid'                       => 'Gültig',
+  'Valid are integer values and floating point numbers, e.g. 4.75h = 4 hours and 45 minutes.' => 'Erlaubt sind ganzzahlige Werte und Kommawerte: Beispiel: 4,75h = 4 Stunden und 45 Minuten.',
   'Valid from'                  => 'Gültig ab',
   'Valid until'                 => 'gültig bis',
   'Valid/Obsolete'              => 'Gültig/ungültig',
@@ -4262,6 +4310,7 @@ $self->{texts} = {
   'list_of_transactions'        => 'buchungsliste',
   'male'                        => 'männlich',
   'max filesize'                => 'maximale Dateigröße',
+  'min'                         => 'min',
   'missing'                     => 'Fehlbestand',
   'missing file for action import' => 'Es wurde keine Datei zum Hochladen ausgewählt',
   'missing_br'                  => 'Fehl.',
@@ -4292,6 +4341,7 @@ $self->{texts} = {
   'not transferred in yet'      => 'noch nicht eingelagert',
   'not transferred out yet'     => 'noch nicht ausgelagert',
   'not yet executed'            => 'Noch nicht ausgeführt',
+  'now'                         => 'jetzt',
   'number'                      => 'Nummer',
   'oe.pl::search called with unknown type' => 'oe.pl::search mit unbekanntem Typ aufgerufen',
   'old'                         => 'alt',
@@ -4376,6 +4426,7 @@ $self->{texts} = {
   'taxnumber'                   => 'Automatikkonto',
   'terminated'                  => 'gekündigt',
   'time and effort based position' => 'Aufwandsposition',
+  'time_recordings'             => 'zeiterfassung',
   'to'                          => 'bis',
   'to (date)'                   => 'bis',
   'to (set to)'                 => 'auf',
index 5d39813..204237e 100644 (file)
@@ -241,6 +241,7 @@ $self->{texts} = {
   'Add sub function block'      => '',
   'Add taxzone'                 => '',
   'Add text block'              => '',
+  'Add time recording article'  => '',
   'Add title'                   => '',
   'Add unit'                    => '',
   'Added sections and function blocks: #1' => '',
@@ -484,6 +485,7 @@ $self->{texts} = {
   'Bis Konto: '                 => '',
   'Body'                        => '',
   'Body:'                       => '',
+  'Booked'                      => '',
   'Booking group'               => '',
   'Booking group #1 needs a valid expense account' => '',
   'Booking group #1 needs a valid income account' => '',
@@ -588,6 +590,7 @@ $self->{texts} = {
   'Cannot transfer negative entries.' => '',
   'Cannot transfer negative quantities.' => '',
   'Cannot transfer. <br> Reason:<br>#1' => '',
+  'Cannot undo delivery order transfer!' => '',
   'Cannot unlink payment for a closed period!' => '',
   'Carry over account for year-end closing' => '',
   'Carry over shipping address' => '',
@@ -792,6 +795,7 @@ $self->{texts} = {
   'Create with profile \'Factur-X 1.0.05/ZUGFeRD 2.1.1 extended\' (test mode)' => '',
   'Create with profile \'XRechnung 2.0.0\'' => '',
   'Create with profile \'XRechnung 2.0.0\' (test mode)' => '',
+  'Create, edit and list time recordings' => '',
   'Created by'                  => '',
   'Created for'                 => '',
   'Created on'                  => '',
@@ -856,6 +860,7 @@ $self->{texts} = {
   'Customer deleted!'           => '',
   'Customer details'            => '',
   'Customer missing!'           => '',
+  'Customer must not be empty.' => '',
   'Customer not found'          => '',
   'Customer saved'              => '',
   'Customer saved!'             => '',
@@ -910,6 +915,7 @@ $self->{texts} = {
   'Database Management'         => '',
   'Database Superuser'          => '',
   'Database User'               => '',
+  'Database errors: #1'         => '',
   'Database host and port'      => '',
   'Database login (#1)'         => '',
   'Database name'               => '',
@@ -978,6 +984,7 @@ $self->{texts} = {
   'Default transport article number' => '',
   'Default unit'                => '',
   'Default value'               => '',
+  'Defines the interval where undoing transfers from a delivery order are allowed.' => '',
   'Delete'                      => '',
   'Delete Account'              => '',
   'Delete Attachments'          => '',
@@ -1035,6 +1042,7 @@ $self->{texts} = {
   'Description (Click on Description for details)' => '',
   'Description (translation for #1)' => '',
   'Description missing!'        => '',
+  'Description must not be empty.' => '',
   'Description of #1'           => '',
   'Design custom data export queries' => '',
   'Destination BIC'             => '',
@@ -1058,6 +1066,7 @@ $self->{texts} = {
   'Discounts'                   => '',
   'Display'                     => '',
   'Display file'                => '',
+  'Display in basic data tab'   => '',
   'Display options'             => '',
   'Displayable Name Preferences' => '',
   'Do not change the tax rate of taxkey 0.' => '',
@@ -1153,6 +1162,7 @@ $self->{texts} = {
   'Duplicate'                   => '',
   'Duplicate in CSV file'       => '',
   'Duplicate in database'       => '',
+  'Duration'                    => '',
   'During the next update a taxkey 0 with tax rate of 0 will automatically created.' => '',
   'E Mail'                      => '',
   'E-Mail'                      => '',
@@ -1267,6 +1277,8 @@ $self->{texts} = {
   'Edit the request_quotation'  => '',
   'Edit the sales_order'        => '',
   'Edit the sales_quotation'    => '',
+  'Edit time recording article' => '',
+  'Edit time recordings of all staff members' => '',
   'Edit title'                  => '',
   'Edit units'                  => '',
   'Edit user signature'         => '',
@@ -1281,12 +1293,14 @@ $self->{texts} = {
   'Employee #1 saved!'          => '',
   'Employee (database ID)'      => '',
   'Employee from the original invoice' => '',
+  'Employee must not be empty.' => '',
   'Employees'                   => '',
   'Employees with read access to the project\'s invoices' => '',
   'Empty selection for warehouse will not be added, even if the old bin is still visible (use back and forth to edit again).' => '',
   'Empty transaction!'          => '',
   'Enabled Quick Searched'      => '',
   'Enabled modules'             => '',
+  'End'                         => '',
   'End date'                    => '',
   'Enter longdescription'       => '',
   'Enter the requested execution date or leave empty for the quickest possible execution:' => '',
@@ -1294,6 +1308,7 @@ $self->{texts} = {
   'Entries for which automatic conversion succeeded:' => '',
   'Entries ready to import'     => '',
   'Entries with errors'         => '',
+  'Entry overlaps with "#1".'   => '',
   'Equity'                      => '',
   'Erfolgsrechnung'             => '',
   'Error'                       => '',
@@ -1457,6 +1472,7 @@ $self->{texts} = {
   'Feb'                         => '',
   'February'                    => '',
   'Fee'                         => '',
+  'Fetch order'                 => '',
   'Field'                       => '',
   'File'                        => '',
   'File \'#1\' is used as new Version !' => '',
@@ -1470,6 +1486,7 @@ $self->{texts} = {
   'Files'                       => '',
   'Files from customer'         => '',
   'Files from parts'            => '',
+  'Files from projects'         => '',
   'Files from vendor'           => '',
   'Filter'                      => '',
   'Filter by Partsgroups'       => '',
@@ -1563,6 +1580,9 @@ $self->{texts} = {
   'General settings'            => '',
   'Generate and print sales delivery orders' => '',
   'Germany'                     => '',
+  'Get one order'               => '',
+  'Get one order by shopordernumber' => '',
+  'Get one shoporder'           => '',
   'Get shoporders'              => 'Get and process orders from a web shop',
   'Git revision: #1, #2 #3'     => '',
   'Given Name'                  => '',
@@ -1893,6 +1913,7 @@ $self->{texts} = {
   'List of jobs'                => '',
   'List of tax zones'           => '',
   'List open SEPA exports'      => '',
+  'List time recordings of all staff members' => '',
   'Listprice'                   => '',
   'Load'                        => '',
   'Load an existing draft'      => '',
@@ -2135,6 +2156,7 @@ $self->{texts} = {
   'No template has been selected yet.' => '',
   'No text blocks have been created for this position.' => '',
   'No text has been entered yet.' => '',
+  'No time recordings to convert' => '',
   'No title yet'                => '',
   'No transaction on chart bank chosen!' => '',
   'No transaction selected!'    => '',
@@ -2185,8 +2207,8 @@ $self->{texts} = {
   'Number of copies'            => '',
   'Number of data sets'         => '',
   'Number of data uploaded:'    => '',
-  'Number of deliveryorders created:' => '',
-  'Number of deliveryorders printed:' => '',
+  'Number of delivery orders created:' => '',
+  'Number of delivery orders printed:' => '',
   'Number of entries changed: #1' => '',
   'Number of invoices'          => '',
   'Number of invoices created:' => '',
@@ -2239,6 +2261,7 @@ $self->{texts} = {
   'OpenDocument/OASIS'          => '',
   'Openings'                    => '',
   'Option'                      => '',
+  'Optional'                    => '',
   'Optional comment'            => '',
   'Options'                     => '',
   'Or download the whole Installation Documentation as PDF (350kB) for off-line study (currently in German Language): ' => '',
@@ -2296,6 +2319,7 @@ $self->{texts} = {
   'PLZ Grosskunden'             => '',
   'POSTED'                      => '',
   'POSTED AS NEW'               => '',
+  'PREVIEWED'                   => '',
   'PRINTED'                     => '',
   'Package name'                => '',
   'Packing Lists'               => '',
@@ -2552,6 +2576,7 @@ $self->{texts} = {
   'Project (description)'       => '',
   'Project (number)'            => '',
   'Project Description'         => '',
+  'Project Details'             => '',
   'Project Link'                => '',
   'Project Number'              => '',
   'Project Numbers'             => '',
@@ -2752,6 +2777,7 @@ $self->{texts} = {
   'Reset'                       => '',
   'Result'                      => '',
   'Result of SQL query'         => '',
+  'Results per page'            => '',
   'Revenue'                     => '',
   'Revenue Account'             => '',
   'Reversal invoices cannot be canceled.' => '',
@@ -2858,6 +2884,7 @@ $self->{texts} = {
   'Save and close'              => '',
   'Save and execute'            => '',
   'Save and keep open'          => '',
+  'Save and preview PDF'        => '',
   'Save and print'              => '',
   'Save as a new draft.'        => '',
   'Save as new'                 => '',
@@ -2870,6 +2897,7 @@ $self->{texts} = {
   'Saving failed. Error message from the database: #1' => '',
   'Saving the file \'%s\' failed. OS error message: %s' => '',
   'Saving the record template \'#1\' failed.' => '',
+  'Saving the time recording entry failed: #1' => '',
   'Score'                       => '',
   'Screen'                      => '',
   'Scrollbar height percentage for form postion area (0 means no scrollbar)' => '',
@@ -2981,6 +3009,7 @@ $self->{texts} = {
   'Shop Orders'                 => '',
   'Shop article'                => '',
   'Shop customernumber'         => '',
+  'Shop or ordernumber not selected.' => '',
   'Shop orderdate'              => '',
   'Shop ordernumber'            => '',
   'Shop part'                   => '',
@@ -2990,6 +3019,7 @@ $self->{texts} = {
   'Shopcategories'              => '',
   'Shopimages - valid for all shops' => '',
   'Shoporder'                   => '',
+  'Shoporder "#2" From Shop "#1" is already fetched' => '',
   'Shoporder deleted -- '       => '',
   'Shoporder not found'         => '',
   'Shoporderlock'               => '',
@@ -3100,12 +3130,16 @@ $self->{texts} = {
   'Space'                       => '',
   'Split entry detected. The values you have entered will result in an entry with more than one position on both debit and credit. Due to known problems involving accounting software kivitendo does not allow these.' => '',
   'Spoolfile'                   => '',
+  'Staff member must not be empty.' => '',
+  'Start'                       => '',
   'Start (verb)'                => '',
   'Start Dunning Process'       => '',
   'Start date'                  => '',
   'Start of year'               => '',
   'Start process'               => '',
   'Start the correction assistant' => '',
+  'Start time'                  => '',
+  'Start time must be earlier than end time.' => '',
   'Startdate method'            => '',
   'Startdate_coa'               => '',
   'Starting Balance'            => '',
@@ -3275,6 +3309,7 @@ $self->{texts} = {
   'The LDAP server "#1:#2" is unreachable. Please check config/kivitendo.conf.' => '',
   'The Mail strings have been saved.' => '',
   'The PDF has been created'    => '',
+  'The PDF has been previewed'  => '',
   'The PDF has been printed'    => '',
   'The SEPA export has been created.' => '',
   'The SEPA strings have been saved.' => '',
@@ -3411,6 +3446,7 @@ $self->{texts} = {
   'The following currencies have been used, but they are not defined:' => '',
   'The following delivery orders could not be processed because they are already closed: #1' => '',
   'The following drafts have been saved and can be loaded.' => '',
+  'The following errors occurred:' => '',
   'The following groups are valid for this client' => '',
   'The following is only a preview.' => '',
   'The following list has been generated automatically from existing users collapsing users with identical settings into a single entry.' => '',
@@ -3659,6 +3695,8 @@ $self->{texts} = {
   'This discount is only valid in purchase documents' => '',
   'This discount is only valid in records with customer or vendor' => '',
   'This discount is only valid in sales documents' => '',
+  'This entry is using date and duration. This information will be overwritten on saving.' => '',
+  'This entry is using start and end time. This information will be overwritten on saving.' => '',
   'This export will include all records in the given time range and all supplicant information from checked entities. You will receive a single zip file. Please extract this file onto the data medium requested by your auditor.' => '',
   'This feature especially prevents mistakes by mixing up prior tax and sales tax.' => '',
   'This field must not be empty.' => '',
@@ -3721,6 +3759,9 @@ $self->{texts} = {
   'Threshold for warning on quantity difference' => '',
   'Time'                        => '',
   'Time Format'                 => '',
+  'Time Recording'              => '',
+  'Time Recording Articles'     => '',
+  'Time Recordings'             => '',
   'Time and price estimate'     => '',
   'Time estimate'               => '',
   'Time period for the analysis:' => '',
@@ -3782,6 +3823,7 @@ $self->{texts} = {
   'Transfer To Stock'           => '',
   'Transfer all marked'         => '',
   'Transfer data to Geierlein ELSTER application' => '',
+  'Transfer date exceeds the maximum allowed interval.' => '',
   'Transfer from warehouse'     => '',
   'Transfer in'                 => '',
   'Transfer in via default'     => '',
@@ -3793,6 +3835,7 @@ $self->{texts} = {
   'Transfer qty'                => '',
   'Transfer services via default' => '',
   'Transfer successful'         => '',
+  'Transfer undone.'            => '',
   'Transferred'                 => '',
   'Translation'                 => '',
   'Translations'                => '',
@@ -3811,6 +3854,7 @@ $self->{texts} = {
   'Type of Vendor'              => '',
   'TypeAbbreviation'            => '',
   'Types of Business'           => '',
+  'UNDO TRANSFER'               => '',
   'UNIMPORT'                    => '',
   'USTVA'                       => '',
   'USTVA 2004'                  => '',
@@ -3830,6 +3874,8 @@ $self->{texts} = {
   'Unbalanced Ledger'           => '',
   'Unchecked custom variables will not appear in orders and invoices.' => '',
   'Undo SEPA exports'           => '',
+  'Undo Transfer'               => '',
+  'Undo Transfer Interval'      => '',
   'Unfinished follow-ups'       => '',
   'Unfortunately you have no warehouse defined.' => '',
   'Unimport all'                => '',
@@ -3906,6 +3952,7 @@ $self->{texts} = {
   'Use a text field to enter (new) contact titles if enabled. Otherwise, only a drop down box is offered.' => '',
   'Use a text field to enter (new) greetings if enabled. Otherwise, only a drop down box is offered.' => '',
   'Use as new'                  => '',
+  'Use date and duration for time recordings' => '',
   'Use default booking group because setting is \'all\'' => '',
   'Use default booking group because wanted is missing' => '',
   'Use default warehouse for assembly transfer' => '',
@@ -3944,6 +3991,7 @@ $self->{texts} = {
   'VAT ID and/or taxnumber must be given.' => '',
   'VN'                          => '',
   'Valid'                       => '',
+  'Valid are integer values and floating point numbers, e.g. 4.75h = 4 hours and 45 minutes.' => '',
   'Valid from'                  => '',
   'Valid until'                 => '',
   'Valid/Obsolete'              => '',
@@ -4261,6 +4309,7 @@ $self->{texts} = {
   'list_of_transactions'        => '',
   'male'                        => '',
   'max filesize'                => '',
+  'min'                         => '',
   'missing'                     => '',
   'missing file for action import' => '',
   'missing_br'                  => 'missing',
@@ -4291,6 +4340,7 @@ $self->{texts} = {
   'not transferred in yet'      => '',
   'not transferred out yet'     => '',
   'not yet executed'            => '',
+  'now'                         => '',
   'number'                      => '',
   'oe.pl::search called with unknown type' => '',
   'old'                         => '',
@@ -4375,6 +4425,7 @@ $self->{texts} = {
   'taxnumber'                   => '',
   'terminated'                  => '',
   'time and effort based position' => '',
+  'time_recordings'             => '',
   'to'                          => '',
   'to (date)'                   => '',
   'to (set to)'                 => '',
diff --git a/menus/user/10-time-recording.yaml b/menus/user/10-time-recording.yaml
new file mode 100644 (file)
index 0000000..eab685f
--- /dev/null
@@ -0,0 +1,22 @@
+---
+- parent: system
+  id: system_time_recording_articles
+  name: Time Recording Articles
+  order: 2370
+  params:
+    action: SimpleSystemSetting/list
+    type: time_recording_article
+- parent: productivity
+  id: productivity_time_recording
+  name: Time Recording
+  order: 350
+  access: time_recording
+  params:
+    action: TimeRecording/edit
+- parent: productivity_reports
+  id: productivity_reports_time_recording
+  name: Time Recording
+  order: 300
+  access: time_recording
+  params:
+    action: TimeRecording/list
diff --git a/sql/Pg-upgrade2-auth/right_time_recording.sql b/sql/Pg-upgrade2-auth/right_time_recording.sql
new file mode 100644 (file)
index 0000000..b8e0b93
--- /dev/null
@@ -0,0 +1,15 @@
+-- @tag: right_time_recording
+-- @description: Recht zur Zeiterfassung
+-- @depends: release_3_5_6_1
+-- @locales: Create, edit and list time recordings
+
+INSERT INTO auth.master_rights (position, name, description, category)
+  VALUES ((SELECT position + 50 FROM auth.master_rights WHERE name = 'email_employee_readall'),
+          'time_recording',
+          'Create, edit and list time recordings',
+          FALSE);
+
+INSERT INTO auth.group_rights (group_id, "right", granted)
+  SELECT id, 'time_recording', true
+  FROM auth.group
+  WHERE name = 'Vollzugriff';
diff --git a/sql/Pg-upgrade2-auth/rights_time_recording_show_edit_all.sql b/sql/Pg-upgrade2-auth/rights_time_recording_show_edit_all.sql
new file mode 100644 (file)
index 0000000..24453a9
--- /dev/null
@@ -0,0 +1,27 @@
+-- @tag: rights_time_recording_show_edit_all
+-- @description: Rechte, Zeiterfassungseinträge aller Mitarbeiter anzuzeigen bzw. zu bearbeiten
+-- @depends: right_time_recording
+-- @locales: List time recordings of all staff members
+-- @locales: Edit time recordings of all staff members
+
+INSERT INTO auth.master_rights (position, name, description, category)
+  VALUES ((SELECT position + 20 FROM auth.master_rights WHERE name = 'time_recording'),
+          'time_recording_show_all',
+          'List time recordings of all staff members',
+          FALSE);
+
+INSERT INTO auth.group_rights (group_id, "right", granted)
+  SELECT id, 'time_recording_show_all', true
+  FROM auth.group
+  WHERE name = 'Vollzugriff';
+
+INSERT INTO auth.master_rights (position, name, description, category)
+  VALUES ((SELECT position + 20 FROM auth.master_rights WHERE name = 'time_recording_show_all'),
+          'time_recording_edit_all',
+          'Edit time recordings of all staff members',
+          FALSE);
+
+INSERT INTO auth.group_rights (group_id, "right", granted)
+  SELECT id, 'time_recording_edit_all', true
+  FROM auth.group
+  WHERE name = 'Vollzugriff';
diff --git a/sql/Pg-upgrade2/add_transfer_doc_interval.sql b/sql/Pg-upgrade2/add_transfer_doc_interval.sql
new file mode 100644 (file)
index 0000000..79e2aba
--- /dev/null
@@ -0,0 +1,4 @@
+-- @tag: add_transfer_doc_interval
+-- @description: Konfigurierbarer Zeitraum innerhalb dessen Lieferscheine wieder rückgelagert werden können
+-- @depends: release_3_5_6_1
+ALTER TABLE defaults ADD COLUMN undo_transfer_interval integer DEFAULT 7;
diff --git a/sql/Pg-upgrade2/custom_variables_add_edit_position.sql b/sql/Pg-upgrade2/custom_variables_add_edit_position.sql
new file mode 100644 (file)
index 0000000..efb420d
--- /dev/null
@@ -0,0 +1,6 @@
+-- @tag: custom_variables_add_edit_position
+-- @description: Erweiterung custom_variables
+-- @depends: release_3_5_6_1 custom_variables
+
+ALTER TABLE custom_variable_configs ADD COLUMN first_tab BOOLEAN NOT NULL DEFAULT FALSE;
+
diff --git a/sql/Pg-upgrade2/cvars_remove_dublicate_entries.pl b/sql/Pg-upgrade2/cvars_remove_dublicate_entries.pl
new file mode 100644 (file)
index 0000000..31e8232
--- /dev/null
@@ -0,0 +1,52 @@
+# @tag: cvars_remove_duplicate_entries
+# @description: Doppelte Einträge für gleiche benutzerdefinierte Variablen entfernen (behalte den Neusten).
+# @depends: release_3_4_1
+
+package SL::DBUpgrade2::cvars_remove_duplicate_entries;
+
+use strict;
+use utf8;
+
+use parent qw(SL::DBUpgrade2::Base);
+
+use SL::DBUtils;
+
+sub run {
+  my ($self) = @_;
+
+  # get all duplicates
+  my $query_all_dups = qq|
+    SELECT trans_id, config_id, sub_module FROM custom_variables
+      GROUP BY trans_id, config_id, sub_module
+      HAVING COUNT(*) > 1
+  |;
+
+  my $refs = selectall_hashref_query($::form, $self->dbh, $query_all_dups);
+
+  # remove all but the newest one (order by itime descending)
+  my $query_delete = qq|
+    DELETE FROM custom_variables WHERE id = ?;
+  |;
+  my $sth_delete = $self->dbh->prepare($query_delete);
+
+  my $query_all_but_newest = qq|
+      SELECT id FROM custom_variables WHERE trans_id = ? AND config_id = ? AND sub_module = ? ORDER BY itime DESC OFFSET 1
+  |;
+  my $sth_all_but_newest = $self->dbh->prepare($query_all_but_newest);
+
+  foreach my $ref (@$refs) {
+    my @to_delete_ids;
+    $sth_all_but_newest->execute($ref->{trans_id}, $ref->{config_id}, $ref->{sub_module}) || $::form->dberror($query_all_but_newest);
+    while (my ($row) = $sth_all_but_newest->fetchrow_array()) {
+      push(@to_delete_ids, $row);
+    }
+    ($sth_delete->execute($_) || $::form->dberror($query_delete)) for @to_delete_ids;
+  }
+
+  $sth_all_but_newest->finish;
+  $sth_delete->finish;
+
+  return 1;
+}
+
+1;
diff --git a/sql/Pg-upgrade2/file_storage_project.sql b/sql/Pg-upgrade2/file_storage_project.sql
new file mode 100644 (file)
index 0000000..4ad74e4
--- /dev/null
@@ -0,0 +1,19 @@
+-- @tag: file_storage_project
+-- @description: Dateispeicher auch für Projekte anbieten
+-- @depends: file_storage_dunning_invoice
+
+ALTER TABLE files
+  DROP CONSTRAINT valid_type;
+ALTER TABLE files
+  ADD  CONSTRAINT valid_type CHECK (
+             (object_type = 'credit_note'     ) OR (object_type = 'invoice'                 ) OR (object_type = 'sales_order'          )
+          OR (object_type = 'sales_quotation' ) OR (object_type = 'sales_delivery_order'    ) OR (object_type = 'request_quotation'    )
+          OR (object_type = 'purchase_order'  ) OR (object_type = 'purchase_delivery_order' ) OR (object_type = 'purchase_invoice'     )
+          OR (object_type = 'vendor'          ) OR (object_type = 'customer'                ) OR (object_type = 'part'                 )
+          OR (object_type = 'gl_transaction'  ) OR (object_type = 'dunning'                 ) OR (object_type = 'dunning1'             )
+          OR (object_type = 'dunning2'        ) OR (object_type = 'dunning3'                ) OR (object_type = 'dunning_orig_invoice' )
+          OR (object_type = 'dunning_invoice' ) OR (object_type = 'draft'                   ) OR (object_type = 'statement'            )
+          OR (object_type = 'shop_image'      )
+          OR (object_type = 'letter'          )
+          OR (object_type = 'project'         )
+  );
diff --git a/sql/Pg-upgrade2/orderitems_optional.sql b/sql/Pg-upgrade2/orderitems_optional.sql
new file mode 100644 (file)
index 0000000..3b4ddda
--- /dev/null
@@ -0,0 +1,5 @@
+-- @tag: orderitems_optional
+-- @description: Optionale Artikel im Angebot und Auftrag
+-- @depends: release_3_5_6_1
+ALTER TABLE orderitems ADD COLUMN optional BOOLEAN default FALSE;
+
diff --git a/sql/Pg-upgrade2/time_recordings.sql b/sql/Pg-upgrade2/time_recordings.sql
new file mode 100644 (file)
index 0000000..ccbf329
--- /dev/null
@@ -0,0 +1,35 @@
+-- @tag: time_recordings
+-- @description: Tabellen zur Zeiterfassung
+-- @depends: release_3_5_6_1
+
+CREATE TABLE time_recording_types (
+  id                 SERIAL,
+  abbreviation       TEXT     NOT NULL,
+  description        TEXT,
+  position           INTEGER  NOT NULL,
+  obsolete           BOOLEAN  NOT NULL DEFAULT false,
+  PRIMARY KEY (id)
+);
+
+CREATE TABLE time_recordings (
+  id                SERIAL,
+  customer_id       INTEGER   NOT NULL,
+  project_id        INTEGER,
+  start_time        TIMESTAMP NOT NULL,
+  end_time          TIMESTAMP,
+  type_id           INTEGER,
+  description       TEXT      NOT NULL,
+  staff_member_id   INTEGER   NOT NULL,
+  employee_id       INTEGER   NOT NULL,
+  itime             TIMESTAMP NOT NULL DEFAULT now(),
+  mtime             TIMESTAMP NOT NULL DEFAULT now(),
+
+  PRIMARY KEY (id),
+  FOREIGN KEY (customer_id)     REFERENCES customer (id),
+  FOREIGN KEY (staff_member_id) REFERENCES employee (id),
+  FOREIGN KEY (employee_id)     REFERENCES employee (id),
+  FOREIGN KEY (project_id)      REFERENCES project (id),
+  FOREIGN KEY (type_id)         REFERENCES time_recording_types (id)
+);
+
+CREATE TRIGGER mtime_time_recordings BEFORE UPDATE ON time_recordings FOR EACH ROW EXECUTE PROCEDURE set_mtime();
diff --git a/sql/Pg-upgrade2/time_recordings2.sql b/sql/Pg-upgrade2/time_recordings2.sql
new file mode 100644 (file)
index 0000000..afebb21
--- /dev/null
@@ -0,0 +1,6 @@
+-- @tag: time_recordings2
+-- @description: Ergänzung zur Zeiterfassung
+-- @depends: time_recordings
+ALTER TABLE time_recordings ADD column booked boolean DEFAULT false;
+ALTER TABLE time_recordings ADD column payroll boolean DEFAULT false;
+
diff --git a/sql/Pg-upgrade2/time_recordings_add_order.sql b/sql/Pg-upgrade2/time_recordings_add_order.sql
new file mode 100644 (file)
index 0000000..a9b1a0d
--- /dev/null
@@ -0,0 +1,5 @@
+-- @tag: time_recordings_add_order
+-- @description: Erweiterung Zeiterfassung um Fremdschlüssel zu Auftrag
+-- @depends: time_recordings_date_duration
+
+ALTER TABLE time_recordings ADD COLUMN order_id INTEGER REFERENCES oe (id);
diff --git a/sql/Pg-upgrade2/time_recordings_articles.sql b/sql/Pg-upgrade2/time_recordings_articles.sql
new file mode 100644 (file)
index 0000000..c693c36
--- /dev/null
@@ -0,0 +1,13 @@
+-- @tag: time_recordings_articles
+-- @description: Zeiterfassungs-Artikel
+-- @depends: time_recordings
+
+CREATE TABLE time_recording_articles (
+  id                 SERIAL,
+  part_id            INTEGER  REFERENCES parts(id) UNIQUE NOT NULL,
+  position           INTEGER  NOT NULL,
+
+  PRIMARY KEY (id)
+);
+
+ALTER TABLE time_recordings ADD COLUMN part_id INTEGER REFERENCES parts(id);
diff --git a/sql/Pg-upgrade2/time_recordings_date_duration.sql b/sql/Pg-upgrade2/time_recordings_date_duration.sql
new file mode 100644 (file)
index 0000000..98404e1
--- /dev/null
@@ -0,0 +1,38 @@
+-- @tag: time_recordings_date_duration
+-- @description: Erweiterung Zeiterfassung um Datum und Dauer
+-- @depends: time_recordings2
+
+ALTER TABLE time_recordings ADD   COLUMN date     DATE;
+ALTER TABLE time_recordings ADD   COLUMN duration INTEGER;
+
+UPDATE time_recordings SET date = start_time::DATE;
+ALTER TABLE time_recordings ALTER COLUMN start_time DROP NOT NULL;
+ALTER TABLE time_recordings ALTER COLUMN date SET NOT NULL;
+
+UPDATE time_recordings SET duration = EXTRACT(EPOCH FROM (end_time - start_time))/60;
+
+-- trigger to set date from start_time
+CREATE OR REPLACE FUNCTION time_recordings_set_date_trigger()
+RETURNS TRIGGER AS $$
+  BEGIN
+    IF NEW.start_time IS NOT NULL THEN
+      NEW.date = NEW.start_time::DATE;
+    END IF;
+    RETURN NEW;
+  END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER time_recordings_set_date BEFORE INSERT OR UPDATE ON time_recordings FOR EACH ROW EXECUTE PROCEDURE time_recordings_set_date_trigger();
+
+-- trigger to set duration from start_time and end_time
+CREATE OR REPLACE FUNCTION time_recordings_set_duration_trigger()
+RETURNS TRIGGER AS $$
+  BEGIN
+    IF NEW.start_time IS NOT NULL AND NEW.end_time IS NOT NULL THEN
+      NEW.duration = EXTRACT(EPOCH FROM (NEW.end_time - NEW.start_time))/60;
+    END IF;
+    RETURN NEW;
+  END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER time_recordings_set_duration BEFORE INSERT OR UPDATE ON time_recordings FOR EACH ROW EXECUTE PROCEDURE time_recordings_set_duration_trigger();
diff --git a/sql/Pg-upgrade2/time_recordings_remove_type.sql b/sql/Pg-upgrade2/time_recordings_remove_type.sql
new file mode 100644 (file)
index 0000000..db79bba
--- /dev/null
@@ -0,0 +1,6 @@
+-- @tag: time_recordings_remove_type
+-- @description: Zeiterfassungs-Typen entfernen
+-- @depends: time_recordings time_recordings2
+
+ALTER TABLE time_recordings DROP column type_id;
+DROP TABLE time_recording_types;
diff --git a/t/background_job/convert_time_recordings.t b/t/background_job/convert_time_recordings.t
new file mode 100644 (file)
index 0000000..454af11
--- /dev/null
@@ -0,0 +1,651 @@
+use Test::More tests => 52;
+
+use strict;
+
+use lib 't';
+use utf8;
+
+use Support::TestSetup;
+use Test::Exception;
+use DateTime;
+use Rose::DB::Object::Helpers qw(forget_related);
+
+use SL::DB::BackgroundJob;
+use SL::DB::DeliveryOrder;
+
+use_ok 'SL::BackgroundJob::ConvertTimeRecordings';
+
+use SL::Dev::ALL qw(:ALL);
+
+Support::TestSetup::login();
+
+sub clear_up {
+  foreach (qw(TimeRecording OrderItem Order DeliveryOrder Project Part Customer RecordLink)) {
+    "SL::DB::Manager::${_}"->delete_all(all => 1);
+  }
+  SL::DB::Manager::Employee->delete_all(where => [ '!login' => 'unittests' ]);
+};
+
+########################################
+
+$::myconfig{numberformat} = '1000.00';
+my $old_locale = $::locale;
+# set locale to en so we can match errors
+$::locale = Locale->new('en');
+
+
+clear_up();
+
+########################################
+# two time recordings, one order linked with project_id in time recording entry
+########################################
+my $part     = new_service(partnumber => 'Serv1', unit => 'Std')->save;
+my $project  = create_project(projectnumber => 'p1', description => 'Project 1');
+my $customer = new_customer()->save;
+
+# sales order with globalproject_id
+my $sales_order = create_sales_order(
+  save             => 1,
+  customer         => $customer,
+  globalproject    => $project,
+  taxincluded      => 0,
+  orderitems       => [ create_order_item(part => $part, qty => 3, sellprice => 70), ]
+);
+
+my @time_recordings;
+push @time_recordings, new_time_recording(
+  start_time => DateTime->new(year => 2021, month =>  4, day => 19, hour => 10, minute =>  5),
+  end_time   => DateTime->new(year => 2021, month =>  4, day => 19, hour => 11, minute =>  5),
+  customer   => $customer,
+  project    => $project,
+  part       => $part,
+)->save;
+push @time_recordings, new_time_recording(
+  start_time => DateTime->new(year => 2021, month =>  4, day => 19, hour => 12, minute =>  5),
+  end_time   => DateTime->new(year => 2021, month =>  4, day => 19, hour => 14, minute =>  5),
+  customer   => $customer,
+  project    => $project,
+  part       => $part,
+)->save;
+
+my %data   = (
+  link_order => 1,
+  from_date  => '01.01.2021',
+  to_date    => '30.04.2021',
+);
+my $db_obj = SL::DB::BackgroundJob->new();
+$db_obj->set_data(%data);
+my $job    = SL::BackgroundJob::ConvertTimeRecordings->new;
+my $ret    = $job->run($db_obj);
+
+is_deeply($job->{job_errors}, [], 'no errros');
+like($ret, qr{^Number of delivery orders created: 1}, 'one delivery order created');
+
+my $linked_dos = $sales_order->linked_records(to => 'DeliveryOrder');
+is(scalar @$linked_dos, 1, 'one delivery order linked to order');
+is($linked_dos->[0]->globalproject_id, $sales_order->globalproject_id, 'project ids match');
+
+my $linked_items = $sales_order->items->[0]->linked_records(to => 'DeliveryOrderItem');
+is(scalar @$linked_items, 1, 'one delivery order item linked to order item');
+is($linked_items->[0]->qty*1, 3, 'qty in delivery order');
+is($linked_items->[0]->base_qty*1, 3, 'base_qty in delivery order');
+
+# reload order and orderitems to get changes to deliverd and ship
+Rose::DB::Object::Helpers::forget_related($sales_order, 'orderitems');
+$sales_order->load;
+
+ok($sales_order->delivered, 'related order is delivered');
+is($sales_order->items->[0]->ship*1, 3, 'ship in related order');
+
+clear_up();
+
+
+########################################
+# two time recordings, one order linked with project_id in time recording entry
+# unit in order is 'min', but part is 'Std'
+########################################
+$part     = new_service(partnumber => 'Serv1', unit => 'Std')->save;
+$project  = create_project(projectnumber => 'p1', description => 'Project 1');
+$customer = new_customer()->save;
+
+$sales_order = create_sales_order(
+  save             => 1,
+  customer         => $customer,
+  globalproject    => $project,
+  taxincluded      => 0,
+  orderitems       => [ create_order_item(part => $part, qty => 180, unit => 'min', sellprice => 70), ]
+);
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(
+  start_time => DateTime->new(year => 2021, month =>  4, day => 19, hour => 10, minute => 10),
+  end_time   => DateTime->new(year => 2021, month =>  4, day => 19, hour => 11, minute => 10),
+  customer   => $customer,
+  project    => $project,
+  part       => $part,
+)->save;
+push @time_recordings, new_time_recording(
+  start_time => DateTime->new(year => 2021, month =>  4, day => 19, hour => 12, minute => 10),
+  end_time   => DateTime->new(year => 2021, month =>  4, day => 19, hour => 14, minute => 10),
+  customer   => $customer,
+  project    => $project,
+  part       => $part,
+)->save;
+
+%data = (
+  link_order => 1,
+  from_date  => '01.04.2021',
+  to_date    => '30.04.2021',
+);
+$db_obj = SL::DB::BackgroundJob->new();
+$db_obj->set_data(%data);
+$job    = SL::BackgroundJob::ConvertTimeRecordings->new;
+$ret    = $job->run($db_obj);
+
+$linked_dos = $sales_order->linked_records(to => 'DeliveryOrder');
+$linked_items = $sales_order->items->[0]->linked_records(to => 'DeliveryOrderItem');
+is($linked_items->[0]->qty*1, 3, 'different units: qty in delivery order');
+is($linked_items->[0]->base_qty*1, 3, 'different units: base_qty in delivery order');
+
+# reload order and orderitems to get changes to deliverd and ship
+Rose::DB::Object::Helpers::forget_related($sales_order, 'orderitems');
+$sales_order->load;
+
+ok($sales_order->delivered, 'different units: related order is delivered');
+is($sales_order->items->[0]->ship*1, 180, 'different units: ship in related order');
+
+clear_up();
+
+
+########################################
+# two time recordings, one order linked with project_id in time recording entry
+# unit in order is 'Std', but part is 'min'
+########################################
+$part     = new_service(partnumber => 'Serv1', unit => 'min')->save;
+$project  = create_project(projectnumber => 'p1', description => 'Project 1');
+$customer = new_customer()->save;
+
+$sales_order = create_sales_order(
+  save             => 1,
+  customer         => $customer,
+  globalproject    => $project,
+  taxincluded      => 0,
+  orderitems       => [ create_order_item(part => $part, qty => 2, unit => 'Std', sellprice => 70), ]
+);
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(
+  start_time => DateTime->new(year => 2021, month =>  4, day => 19, hour => 10, minute => 10),
+  end_time   => DateTime->new(year => 2021, month =>  4, day => 19, hour => 11, minute => 10),
+  customer   => $customer,
+  project    => $project,
+  part       => $part,
+)->save;
+push @time_recordings, new_time_recording(
+  start_time => DateTime->new(year => 2021, month =>  4, day => 19, hour => 12, minute => 10),
+  end_time   => DateTime->new(year => 2021, month =>  4, day => 19, hour => 13, minute => 10),
+  customer   => $customer,
+  project    => $project,
+  part       => $part,
+)->save;
+
+%data = (
+  link_order => 1,
+  from_date  => '01.04.2021',
+  to_date    => '30.04.2021',
+);
+$db_obj = SL::DB::BackgroundJob->new();
+$db_obj->set_data(%data);
+$job    = SL::BackgroundJob::ConvertTimeRecordings->new;
+$ret    = $job->run($db_obj);
+
+$linked_dos = $sales_order->linked_records(to => 'DeliveryOrder');
+$linked_items = $sales_order->items->[0]->linked_records(to => 'DeliveryOrderItem');
+is($linked_items->[0]->qty*1, 2, 'different units 2: qty in delivery order');
+is($linked_items->[0]->base_qty*1, 120, 'different units 2: base_qty in delivery order');
+
+# reload order and orderitems to get changes to deliverd and ship
+Rose::DB::Object::Helpers::forget_related($sales_order, 'orderitems');
+$sales_order->load;
+
+ok($sales_order->delivered, 'different units 2: related order is delivered');
+is($sales_order->items->[0]->ship*1, 2, 'different units 2: ship in related order');
+
+clear_up();
+
+
+########################################
+# two time recordings, one with start/end one with date/duration
+########################################
+$part     = new_service(partnumber => 'Serv1', unit => 'min')->save;
+$customer = new_customer()->save;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(
+  start_time => DateTime->new(year => 2021, month =>  4, day => 19, hour => 10, minute => 10),
+  end_time   => DateTime->new(year => 2021, month =>  4, day => 19, hour => 11, minute => 10),
+  customer   => $customer,
+  part       => $part,
+)->save;
+
+push @time_recordings, new_time_recording(
+  date       => DateTime->new(year => 2021, month =>  4, day => 19),
+  duration   => 120,
+  start_time => undef,
+  end_time   => undef,
+  customer   => $customer,
+  part       => $part,
+)->save;
+
+%data = (
+  link_order => 0,
+  from_date  => '01.04.2021',
+  to_date    => '30.04.2021',
+);
+$db_obj = SL::DB::BackgroundJob->new();
+$db_obj->set_data(%data);
+$job    = SL::BackgroundJob::ConvertTimeRecordings->new;
+$ret    = $job->run($db_obj);
+
+my $dos = SL::DB::Manager::DeliveryOrder->get_all(where => [customer_id => $customer->id]);
+is($dos->[0]->items->[0]->qty*1, 180/60, 'date/duration and start/end: qty in delivery order');
+is($dos->[0]->items->[0]->base_qty*1, 180, 'date/duration and start/end2: base_qty in delivery order');
+
+clear_up();
+
+
+########################################
+# time recording, linked with order_id
+########################################
+$part     = new_service(partnumber => 'Serv1', unit => 'Std')->save;
+$customer = new_customer()->save;
+
+# sales order with globalproject_id
+$sales_order = create_sales_order(
+  save             => 1,
+  customer         => $customer,
+  taxincluded      => 0,
+  orderitems       => [ create_order_item(part => $part, qty => 3, sellprice => 70), ]
+);
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(
+  start_time => DateTime->new(year => 2021, month =>  4, day => 19, hour => 10, minute =>  5),
+  end_time   => DateTime->new(year => 2021, month =>  4, day => 19, hour => 11, minute =>  5),
+  customer   => $customer,
+  order      => $sales_order,
+  part       => $part,
+)->save;
+
+%data = (
+  link_order      => 1,
+  from_date       => '01.04.2021',
+  to_date         => '30.04.2021',
+  customernumbers => [$customer->number],
+);
+$db_obj = SL::DB::BackgroundJob->new();
+$db_obj->set_data(%data);
+$job    = SL::BackgroundJob::ConvertTimeRecordings->new;
+$ret    = $job->run($db_obj);
+
+is_deeply($job->{job_errors}, [], 'no errros');
+like($ret, qr{^Number of delivery orders created: 1}, 'linked by order_id: one delivery order created');
+
+$linked_dos = $sales_order->linked_records(to => 'DeliveryOrder');
+is(scalar @$linked_dos, 1, 'linked by order_id: one delivery order linked to order');
+
+$linked_items = $sales_order->items->[0]->linked_records(to => 'DeliveryOrderItem');
+is(scalar @$linked_items, 1, 'linked by order_id: one delivery order item linked to order item');
+is($linked_items->[0]->qty*1, 1, 'linked by order_id: qty in delivery order');
+is($linked_items->[0]->base_qty*1, 1, 'linked by order_id: base_qty in delivery order');
+
+# reload order and orderitems to get changes to deliverd and ship
+Rose::DB::Object::Helpers::forget_related($sales_order, 'orderitems');
+$sales_order->load;
+
+is($sales_order->items->[0]->ship*1, 1, 'linked by order_id: ship in related order');
+
+clear_up();
+
+
+########################################
+# override project and part
+########################################
+$part     = new_service(partnumber => 'Serv1', unit => 'Std')->save;
+my $part2    = new_service(partnumber => 'Serv2', unit => 'min')->save;
+$project  = create_project(projectnumber => 'p1', description => 'Project 1');
+my $project2 = create_project(projectnumber => 'p2', description => 'Project 2');
+$customer = new_customer()->save;
+
+$sales_order = create_sales_order(
+  save             => 1,
+  customer         => $customer,
+  globalproject    => $project,
+  taxincluded      => 0,
+  orderitems       => [ create_order_item(part => $part, qty => 180, unit => 'min', sellprice => 70), ]
+);
+my $sales_order2 = create_sales_order(
+  save             => 1,
+  customer         => $customer,
+  globalproject    => $project,
+  taxincluded      => 0,
+  orderitems       => [ create_order_item(part => $part2, qty => 180, unit => 'min', sellprice => 70), ]
+);
+
+new_time_recording(
+  start_time => DateTime->new(year => 2021, month =>  4, day => 19, hour => 10, minute => 10),
+  end_time   => DateTime->new(year => 2021, month =>  4, day => 19, hour => 11, minute => 10),
+  customer   => $customer,
+  project    => $project,
+  part       => $part,
+)->save;
+new_time_recording(
+  start_time => DateTime->new(year => 2021, month =>  4, day => 19, hour => 11, minute => 10),
+  end_time   => DateTime->new(year => 2021, month =>  4, day => 19, hour => 12, minute => 10),
+  customer   => $customer,
+  project    => $project2,
+  part       => $part2,
+)->save;
+new_time_recording(
+  start_time => DateTime->new(year => 2021, month =>  4, day => 19, hour => 12, minute => 10),
+  end_time   => DateTime->new(year => 2021, month =>  4, day => 19, hour => 13, minute => 10),
+  customer   => $customer,
+)->save;
+
+%data = (
+  link_order          => 1,
+  from_date           => '01.04.2021',
+  to_date             => '30.04.2021',
+  override_part_id    => $part->id,
+  override_project_id => $project->id,
+);
+$db_obj = SL::DB::BackgroundJob->new();
+$db_obj->set_data(%data);
+$job    = SL::BackgroundJob::ConvertTimeRecordings->new;
+$ret    = $job->run($db_obj);
+
+$linked_dos = $sales_order->linked_records(to => 'DeliveryOrder');
+is($linked_dos->[0]->globalproject_id, $project->id, 'overriden part and project: project in delivery order');
+
+$linked_items = $sales_order->items->[0]->linked_records(to => 'DeliveryOrderItem');
+is($linked_items->[0]->qty*1, 3, 'overriden part and project: qty in delivery order');
+is($linked_items->[0]->base_qty*1, 3, 'overriden part and project: base_qty in delivery order');
+is($linked_items->[0]->parts_id, $part->id, 'overriden part and project: part id');
+
+my $linked_dos2 = $sales_order2->linked_records(to => 'DeliveryOrder');
+is(scalar @$linked_dos2, 0, 'overriden part and project: no delivery order for unused order');
+
+# reload order and orderitems to get changes to deliverd and ship
+Rose::DB::Object::Helpers::forget_related($sales_order, 'orderitems');
+$sales_order->load;
+Rose::DB::Object::Helpers::forget_related($sales_order2, 'orderitems');
+$sales_order2->load;
+
+is($sales_order ->items->[0]->ship||0, 180, 'overriden part and project: ship in related order');
+is($sales_order2->items->[0]->ship||0,   0, 'overriden part and project: ship in not related order');
+
+clear_up();
+
+
+########################################
+# default project and part
+########################################
+$part     = new_service(partnumber => 'Serv1', unit => 'Std')->save;
+$project  = create_project(projectnumber => 'p1', description => 'Project 1');
+$customer = new_customer()->save;
+
+$sales_order = create_sales_order(
+  save             => 1,
+  customer         => $customer,
+  globalproject    => $project,
+  taxincluded      => 0,
+  orderitems       => [ create_order_item(part => $part, qty => 180, unit => 'min', sellprice => 70), ]
+);
+
+new_time_recording(
+  start_time => DateTime->new(year => 2021, month =>  4, day => 19, hour => 10, minute => 10),
+  end_time   => DateTime->new(year => 2021, month =>  4, day => 19, hour => 11, minute => 40),
+  customer   => $customer,
+)->save;
+
+%data = (
+  link_order         => 1,
+  from_date          => '01.04.2021',
+  to_date            => '30.04.2021',
+  default_part_id    => $part->id,
+  default_project_id => $project->id,
+);
+$db_obj = SL::DB::BackgroundJob->new();
+$db_obj->set_data(%data);
+$job    = SL::BackgroundJob::ConvertTimeRecordings->new;
+$ret    = $job->run($db_obj);
+
+$linked_dos = $sales_order->linked_records(to => 'DeliveryOrder');
+is($linked_dos->[0]->globalproject_id, $project->id, 'default and project: project in delivery order');
+
+$linked_items = $sales_order->items->[0]->linked_records(to => 'DeliveryOrderItem');
+is($linked_items->[0]->qty*1, 1.5, 'default part and project: qty in delivery order');
+is($linked_items->[0]->base_qty*1, 1.5, 'default part and project: base_qty in delivery order');
+is($linked_items->[0]->parts_id, $part->id, 'default part and project: part id');
+
+# reload order and orderitems to get changes to deliverd and ship
+Rose::DB::Object::Helpers::forget_related($sales_order, 'orderitems');
+$sales_order->load;
+
+is($sales_order->items->[0]->ship*1, 90, 'default part and project: ship in related order');
+
+clear_up();
+
+
+########################################
+# check rounding
+########################################
+$part     = new_service(partnumber => 'Serv1', unit => 'Std')->save;
+$customer = new_customer()->save;
+
+$sales_order = create_sales_order(
+  save             => 1,
+  customer         => $customer,
+  taxincluded      => 0,
+  orderitems       => [ create_order_item(part => $part, qty => 3, sellprice => 70), ]
+);
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(
+  start_time => DateTime->new(year => 2021, month =>  4, day => 19, hour => 10, minute =>  0),
+  end_time   => DateTime->new(year => 2021, month =>  4, day => 19, hour => 10, minute =>  6),
+  customer   => $customer,
+  order      => $sales_order,
+  part       => $part,
+)->save;
+
+%data   = (
+  from_date  => '01.01.2021',
+  to_date    => '30.04.2021',
+  link_order => 1,
+  rounding   => 1,
+);
+$db_obj = SL::DB::BackgroundJob->new();
+$db_obj->set_data(%data);
+$job    = SL::BackgroundJob::ConvertTimeRecordings->new;
+$ret    = $job->run($db_obj);
+
+$linked_dos   = $sales_order->linked_records(to => 'DeliveryOrder');
+$linked_items = $sales_order->items->[0]->linked_records(to => 'DeliveryOrderItem');
+is($linked_items->[0]->qty*1, 0.25, 'rounding to quarter hour: qty in delivery order');
+is($linked_items->[0]->base_qty*1, 0.25, 'rounding to quarter hour: base_qty in delivery order');
+
+# reload order and orderitems to get changes to deliverd and ship
+Rose::DB::Object::Helpers::forget_related($sales_order, 'orderitems');
+$sales_order->load;
+
+is($sales_order->items->[0]->ship*1, 0.25, 'rounding to quarter hour: ship in related order');
+
+clear_up();
+
+
+########################################
+# check rounding
+########################################
+$part     = new_service(partnumber => 'Serv1', unit => 'Std')->save;
+$customer = new_customer()->save;
+
+$sales_order = create_sales_order(
+  save             => 1,
+  customer         => $customer,
+  taxincluded      => 0,
+  orderitems       => [ create_order_item(part => $part, qty => 3, sellprice => 70), ]
+);
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(
+  start_time => DateTime->new(year => 2021, month =>  4, day => 19, hour => 10, minute =>  0),
+  end_time   => DateTime->new(year => 2021, month =>  4, day => 19, hour => 10, minute =>  6),
+  customer   => $customer,
+  order      => $sales_order,
+  part       => $part,
+)->save;
+
+%data   = (
+  from_date  => '01.01.2021',
+  to_date    => '30.04.2021',
+  link_order => 1,
+  rounding   => 0,
+);
+$db_obj = SL::DB::BackgroundJob->new();
+$db_obj->set_data(%data);
+$job    = SL::BackgroundJob::ConvertTimeRecordings->new;
+$ret    = $job->run($db_obj);
+
+$linked_dos   = $sales_order->linked_records(to => 'DeliveryOrder');
+$linked_items = $sales_order->items->[0]->linked_records(to => 'DeliveryOrderItem');
+is($linked_items->[0]->qty*1, 0.1, 'no rounding: qty in delivery order');
+is($linked_items->[0]->base_qty*1, 0.1, 'no rounding: base_qty in delivery order');
+
+# reload order and orderitems to get changes to deliverd and ship
+Rose::DB::Object::Helpers::forget_related($sales_order, 'orderitems');
+$sales_order->load;
+
+is($sales_order->items->[0]->ship*1, 0.1, 'no rounding: ship in related order');
+
+clear_up();
+
+
+########################################
+# are wrong params detected?
+########################################
+%data = (
+  from_date       => 'x01.04.2021',
+);
+$db_obj = SL::DB::BackgroundJob->new();
+$db_obj->set_data(%data);
+$job    = SL::BackgroundJob::ConvertTimeRecordings->new;
+
+my $err_msg = '';
+eval { $ret = $job->run($db_obj);  1; } or do {$err_msg = $@};
+ok($err_msg =~ '^Cannot convert date from string', 'wrong date string detected');
+
+#####
+
+$customer = new_customer()->save;
+%data = (
+  customernumbers => ['a fantasy', $customer->number],
+);
+
+$db_obj = SL::DB::BackgroundJob->new();
+$db_obj->set_data(%data);
+$job    = SL::BackgroundJob::ConvertTimeRecordings->new;
+
+$err_msg = '';
+eval { $ret = $job->run($db_obj);  1; } or do {$err_msg = $@};
+ok($err_msg =~ '^Not all customer numbers are valid', 'wrong customer number detected');
+
+#####
+
+%data = (
+  customernumbers => '123',
+);
+
+$db_obj = SL::DB::BackgroundJob->new();
+$db_obj->set_data(%data);
+$job    = SL::BackgroundJob::ConvertTimeRecordings->new;
+
+$err_msg = '';
+eval { $ret = $job->run($db_obj);  1; } or do {$err_msg = $@};
+ok($err_msg =~ '^Customer numbers must be given in an array', 'wrong customer number data type detected');
+
+#####
+
+%data = (
+  override_part_id => '123',
+);
+
+$db_obj = SL::DB::BackgroundJob->new();
+$db_obj->set_data(%data);
+$job    = SL::BackgroundJob::ConvertTimeRecordings->new;
+
+$err_msg = '';
+eval { $ret = $job->run($db_obj);  1; } or do {$err_msg = $@};
+ok($err_msg =~ '^No valid part found by given override part id', 'invalid part id detected');
+
+#####
+
+$part = new_service(partnumber => 'Serv1', unit => 'Std', obsolete => 1)->save;
+%data = (
+  override_part_id => $part->id,
+);
+
+$db_obj = SL::DB::BackgroundJob->new();
+$db_obj->set_data(%data);
+$job    = SL::BackgroundJob::ConvertTimeRecordings->new;
+
+$err_msg = '';
+eval { $ret = $job->run($db_obj);  1; } or do {$err_msg = $@};
+ok($err_msg =~ '^No valid part found by given override part id', 'obsolete part detected');
+
+#####
+
+%data = (
+  override_project_id => 123,
+);
+
+$db_obj = SL::DB::BackgroundJob->new();
+$db_obj->set_data(%data);
+$job    = SL::BackgroundJob::ConvertTimeRecordings->new;
+
+$err_msg = '';
+eval { $ret = $job->run($db_obj);  1; } or do {$err_msg = $@};
+ok($err_msg =~ '^No valid project found by given override project id', 'invalid project id detected');
+
+#####
+
+$project = create_project(projectnumber => 'p1', description => 'Project 1', valid => 0)->save;
+%data = (
+  override_project_id => $project->id,
+);
+
+$db_obj = SL::DB::BackgroundJob->new();
+$db_obj->set_data(%data);
+$job    = SL::BackgroundJob::ConvertTimeRecordings->new;
+
+$err_msg = '';
+eval { $ret = $job->run($db_obj);  1; } or do {$err_msg = $@};
+ok($err_msg =~ '^No valid project found by given override project id', 'invalid project detected');
+
+#####
+
+clear_up();
+
+
+########################################
+
+$::locale = $old_locale;
+
+1;
+
+#####
+# vim: ft=perl
+# set emacs to perl mode
+# Local Variables:
+# mode: perl
+# End:
diff --git a/t/controllers/csvimport/customervendor.t b/t/controllers/csvimport/customervendor.t
new file mode 100644 (file)
index 0000000..2eb987e
--- /dev/null
@@ -0,0 +1,283 @@
+use Test::More tests => 41;
+
+use strict;
+
+use lib 't';
+
+use Support::TestSetup;
+use List::MoreUtils qw(none any);
+
+use SL::DB::Customer;
+use SL::DB::CustomVariableConfig;
+use SL::DB::Default;
+
+use SL::Controller::CsvImport;
+use_ok 'SL::Controller::CsvImport::CustomerVendor';
+
+Support::TestSetup::login();
+
+#####
+sub do_import {
+  my ($file, $settings) = @_;
+
+  my $controller = SL::Controller::CsvImport->new(
+    type => 'customers_vendors',
+  );
+  $controller->load_default_profile;
+  $controller->profile->set(
+    charset  => 'utf-8',
+    sep_char => ';',
+    %$settings
+  );
+
+  my $worker = SL::Controller::CsvImport::CustomerVendor->new(
+    controller => $controller,
+    file       => $file,
+  );
+  $worker->run(test => 0);
+
+  return if $worker->controller->errors;
+
+  # don't try and save objects that have errors
+  $worker->save_objects unless scalar @{$worker->controller->data->[0]->{errors}};
+
+  return $worker->controller->data;
+}
+
+sub _obj_of {
+  return $_[0]->{object_to_save} || $_[0]->{object};
+}
+
+sub clear_up {
+  SL::DB::Manager::Customer->delete_all(all => 1);
+  SL::DB::Manager::CustomVariableConfig->delete_all(all => 1);
+
+  SL::DB::Default->get->update_attributes(customernumber => '10000');
+
+  # Reset request to clear caches. Here especially for cvar-configs.
+  $::request = Support::TestSetup->create_new_request;
+}
+
+#####
+
+# set numberformat and locale (so we can match errors)
+my $old_numberformat      = $::myconfig{numberformat};
+$::myconfig{numberformat} = '1.000,00';
+my $old_locale            = $::locale;
+$::locale                 = Locale->new('en');
+
+clear_up;
+
+#####
+# import and update entries
+
+my $file = \<<EOL;
+name;street;
+CustomerName;CustomerStreet
+EOL
+
+my $entries = do_import($file, {update_policy => 'update_existing'});
+
+ok none {'Updating existing entry in database' eq $_} @{$entries->[0]->{information}}, 'import entry - information (customer)';
+is _obj_of($entries->[0])->customernumber, '10001',          'import entry - number (customer)';
+is _obj_of($entries->[0])->name,           'CustomerName',   'import entry - name (customer)';
+is _obj_of($entries->[0])->street,         'CustomerStreet', 'import entry - street (customer)';
+is _obj_of($entries->[0]),                 $entries->[0]->{object}, 'import entry - object not object_to_save (customer)';
+
+my $default_customernumer = SL::DB::Default->get->load->customernumber;
+is $default_customernumer, '10001', 'import entry - defaults range of numbers (customer)';
+
+my $customer_id = _obj_of($entries->[0])->id;
+
+$entries = undef;
+
+$file = \<<EOL;
+customernumber;name;street;
+10001;CustomerName;NewCustomerStreet
+EOL
+
+$entries = do_import($file, {update_policy => 'update_existing'});
+
+ok any {'Updating existing entry in database' eq $_} @{ $entries->[0]->{information} }, 'update entry - information (customer)';
+is _obj_of($entries->[0])->customernumber, '10001',             'update entry - number (customer)';
+is _obj_of($entries->[0])->name,           'CustomerName',      'update entry - name (customer)';
+is _obj_of($entries->[0])->street,         'NewCustomerStreet', 'update entry - street (customer)';
+is _obj_of($entries->[0]),                 $entries->[0]->{object_to_save}, 'update entry - object is object_to_save (customer)';
+is _obj_of($entries->[0])->id,             $customer_id,        'update entry - same id (customer)';
+$default_customernumer = SL::DB::Default->get->load->customernumber;
+is $default_customernumer, '10001', 'update entry - defaults range of numbers (customer)';
+
+$entries = undef;
+
+$file = \<<EOL;
+customernumber;name;street;
+10001;WrongCustomerName;WrongCustomerStreet
+EOL
+
+$entries = do_import($file, {update_policy => 'skip'});
+
+ok any {'Skipping due to existing entry in database' eq $_} @{ $entries->[0]->{errors} }, 'skip entry - error (customer)';
+
+$default_customernumer = SL::DB::Default->get->load->customernumber;
+is $default_customernumer, '10001', 'skip entry - defaults range of numbers (customer)';
+
+$entries = undef;
+
+clear_up;
+#####
+
+$file = \<<EOL;
+name
+CustomerName
+EOL
+
+$entries = do_import($file);
+
+is scalar @$entries,             1,              'one entry - nuber of entries (customer)';
+is _obj_of($entries->[0])->name, 'CustomerName', 'simple file - name only (customer)';
+
+$entries = undef;
+clear_up;
+
+#####
+$file = \<<EOL;
+customernumber;name
+1;CustomerName1
+2;CustomerName2
+EOL
+
+$entries = do_import($file);
+
+is scalar @$entries,                       2,               'two entries - number of entries (customer)';
+is _obj_of($entries->[0])->name,           'CustomerName1', 'two entries, number and name - name  (customer)';
+is _obj_of($entries->[0])->customernumber, '1',             'two entries, number and name - number  (customer)';
+is _obj_of($entries->[1])->name,           'CustomerName2', 'two entries, number and name - name  (customer)';
+is _obj_of($entries->[1])->customernumber, '2',             'two entries, number and name - number  (customer)';
+
+$entries = undef;
+clear_up;
+
+#####
+$file = \<<EOL;
+name;creditlimit;discount
+CustomerName1;1.280,50;0,035
+EOL
+
+$entries = do_import($file);
+
+is scalar @$entries,                     1,              'creditlimit/discount - number of entries (customer)';
+is _obj_of($entries->[0])->name,        'CustomerName1', 'creditlimit/discount - name  (customer)';
+is _obj_of($entries->[0])->creditlimit, 1280.5,          'creditlimit/discount - creditlimit (customer))';
+# Should discount be given in percent or in decimal?
+is _obj_of($entries->[0])->discount,   0.035,            'creditlimit/discount - discount (customer)';
+
+$entries = undef;
+clear_up;
+
+#####
+# Test import with cvars.
+# Customer/vendor cvars can have a default value, so the following cases are to be
+# tested
+# - new customer in csv - no cvars given -> one should be unset, the other one
+#   should have the default value
+# - new customer in csv - both cvars given -> cvars should have the given values
+# - update customer with no cvars in csv -> cvars should not change
+# - update customer with both cvars in csv -> cvars should have the given values
+# (not explicitly testet: does an empty cvar field means to unset the cvar or to
+# leave it untouched?)
+
+# create cvars
+SL::DB::CustomVariableConfig->new(
+  module              => 'CT',
+  name                => 'no_default',
+  description         => 'no default',
+  type                => 'text',
+  searchable          => 1,
+  sortkey             => 1,
+  includeable         => 0,
+  included_by_default => 0,
+)->save;
+
+SL::DB::CustomVariableConfig->new(
+  module              => 'CT',
+  name                => 'with_default',
+  description         => 'with default',
+  type                => 'text',
+  default_value       => 'this is the default',
+  searchable          => 1,
+  sortkey             => 1,
+  includeable         => 0,
+  included_by_default => 0,
+)->save;
+
+# - new customer in csv - no cvars given -> one should be unset, the other one
+#   should have the default value
+$file = \<<EOL;
+customernumber;name;
+1;CustomerName1
+EOL
+
+$entries = do_import($file);
+
+is _obj_of($entries->[0])->customernumber,                      '1',                   'cvar test - import customer 1 with no cvars - number (customer)';
+is _obj_of($entries->[0])->cvar_by_name('no_default')->value,   undef,                 'cvar test - import customer 1 - do not set ungiven cvar which has no default';
+is _obj_of($entries->[0])->cvar_by_name('with_default')->value, 'this is the default', 'cvar test - import customer 1 - do set ungiven cvar which has default';
+
+$entries = undef;
+
+# - new customer in csv - both cvars given -> cvars should have the given values
+$file = \<<EOL;
+customernumber;name;cvar_no_default;cvar_with_default
+2;CustomerName2;"new cvar value abc";"new cvar value xyz"
+EOL
+
+$entries = do_import($file);
+
+is _obj_of($entries->[0])->customernumber,                      '2',                  'cvar test - import customer 2 with cvars - number (customer)';
+is _obj_of($entries->[0])->cvar_by_name('no_default')->value,   'new cvar value abc', 'cvar test - import customer 2 - do set given cvar which has default';
+is _obj_of($entries->[0])->cvar_by_name('with_default')->value, 'new cvar value xyz', 'cvar test - import customer 2 - do set given cvar which has default';
+
+$entries = undef;
+
+# - update customer with no cvars in csv -> cvars should not change
+$file = \<<EOL;
+customernumber;name;street
+1;CustomerName1;"street cs1"
+EOL
+
+$entries = do_import($file, {update_policy => 'update_existing'});
+is _obj_of($entries->[0])->customernumber,                      '1',                   'cvar test - update customer 1 - number (customer)';
+is _obj_of($entries->[0])->street,                              'street cs1',          'cvar test - update customer 1 - set new street (customer)';
+is _obj_of($entries->[0])->cvar_by_name('no_default')->value,   undef,                 'cvar test - update customer 1 - do not set ungiven cvar which has no default';
+is _obj_of($entries->[0])->cvar_by_name('with_default')->value, 'this is the default', 'cvar test - update customer 1 - do set ungiven cvar which has default';
+
+$entries = undef;
+
+# - update customer with both cvars in csv -> cvars should have the given values
+$file = \<<EOL;
+customernumber;name;street;cvar_no_default;cvar_with_default
+1;CustomerName1;"new street cs1";totaly new cvar 123;totaly new cvar abc
+EOL
+
+$entries = do_import($file, {update_policy => 'update_existing'});
+is _obj_of($entries->[0])->customernumber,                      '1',                   'cvar test - update customer 1 - number (customer)';
+is _obj_of($entries->[0])->street,                              'new street cs1',      'cvar test - update customer 1 - set new street (customer)';
+is _obj_of($entries->[0])->cvar_by_name('no_default')->value,   'totaly new cvar 123', 'cvar test - update customer 1 - do set given cvar which has no default (customer)';
+is _obj_of($entries->[0])->cvar_by_name('with_default')->value, 'totaly new cvar abc', 'cvar test - update customer 1 - do set given cvar which has default (customer)';
+
+$entries = undef;
+
+
+clear_up;
+
+$::myconfig{numberformat} = $old_numberformat;
+$::locale                 = $old_locale;
+
+1;
+
+#####
+# vim: ft=perl
+# set emacs to perl mode
+# Local Variables:
+# mode: perl
+# End:
diff --git a/t/db/time_recordig.t b/t/db/time_recordig.t
new file mode 100644 (file)
index 0000000..33b0364
--- /dev/null
@@ -0,0 +1,582 @@
+use Test::More tests => 40;
+
+use strict;
+
+use lib 't';
+use utf8;
+
+use Support::TestSetup;
+use Test::Exception;
+use DateTime;
+
+use_ok 'SL::DB::TimeRecording';
+
+use SL::Dev::ALL qw(:ALL);
+
+Support::TestSetup::login();
+
+my @time_recordings;
+my ($s1, $e1, $s2, $e2);
+
+sub clear_up {
+  foreach (qw(TimeRecording Customer)) {
+    "SL::DB::Manager::${_}"->delete_all(all => 1);
+  }
+  SL::DB::Manager::Employee->delete_all(where => [ '!login' => 'unittests' ]);
+};
+
+########################################
+
+$s1 = DateTime->now_local;
+$e1 = $s1->clone;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1);
+
+ok( $time_recordings[0]->is_time_in_wrong_order, 'same start and end detected' );
+ok( !$time_recordings[0]->is_time_overlapping, 'not overlapping if only one time recording entry in db' );
+
+###
+$time_recordings[0]->end_time(undef);
+ok( !$time_recordings[0]->is_time_in_wrong_order, 'order ok if no end' );
+
+########################################
+# ------------s1-----e1-----
+# --s2---e2-----------------
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 15, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 11, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2)->save;
+
+ok( !$time_recordings[0]->is_time_overlapping, 'not overlapping: completely before 1' );
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: completely before 2' );
+
+
+# -------s1-----e1----------
+# --s2---e2-----------------
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 15, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2)->save;
+
+ok( !$time_recordings[0]->is_time_overlapping, 'not overlapping: before 1' );
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: before 2' );
+
+# ---s1-----e1--------------
+# ---------------s2---e2----
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 13, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2)->save;
+
+ok( !$time_recordings[0]->is_time_overlapping, 'not overlapping: completely after 1' );
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: completely after 2' );
+
+# ---s1-----e1--------------
+# ----------s2---e2---------
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2)->save;
+
+ok( !$time_recordings[0]->is_time_overlapping, 'not overlapping: after 1' );
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: after 2' );
+
+# -------s1-----e1----------
+# ---s2-----e2--------------
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 15, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour =>  9, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: start before, end inbetween' );
+
+# -------s1-----e1----------
+# -----------s2-----e2------
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 15, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 17, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: start inbetween, end after' );
+
+# ---s1---------e1----------
+# ------s2---e2-------------
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 17, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 15, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: completely inbetween' );
+
+
+# ------s1---e1-------------
+# ---s2---------e2----------
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 17, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: completely oudside' );
+
+
+# ---s1---e1----------------
+# ---s2---------e2----------
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 17, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: same start, end outside' );
+
+# ---s1------e1-------------
+# ------s2---e2-------------
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: start after, same end' );
+
+# ---s1------e1-------------
+# ------s2------------------
+# e2 undef
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e2 = undef;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: start inbetween, no end' );
+
+# ---s1------e1-------------
+# ---s2---------------------
+# e2 undef
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e2 = undef;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: same start, no end' );
+
+# -------s1------e1---------
+# ---s2---------------------
+# e2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e2 = undef;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: start before, no end' );
+
+# -------s1------e1---------
+# -------------------s2-----
+# e2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 16, minute => 0);
+$e2 = undef;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: start after, no end' );
+
+# -------s1------e1---------
+# ---------------s2---------
+# e2 undef
+# -> does not overlap
+
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$e2 = undef;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: same start as other end, no end' );
+
+# -------s1------e1---------
+# -----------e2-------------
+# s2 undef
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 17, minute => 0);
+$s2 = undef;
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: no start, end inbetween' );
+
+# -------s1------e1---------
+# ---------------e2---------
+# s2 undef
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 17, minute => 0);
+$s2 = undef;
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 17, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: no start, same end' );
+
+# -------s1------e1---------
+# --e2----------------------
+# s2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 17, minute => 0);
+$s2 = undef;
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no start, end before' );
+
+# -------s1------e1---------
+# -------------------e2-----
+# s2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 15, minute => 0);
+$s2 = undef;
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 17, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no start, end after' );
+
+# -------s1------e1---------
+# -------e2-----------------
+# s2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 15, minute => 0);
+$s2 = undef;
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no start, same end as other start' );
+
+# ----s1--------------------
+# ----s2-----e2-------------
+# e1 undef
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = undef;
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: no end in db, same start' );
+
+# --------s1----------------
+# ----s2-----e2-------------
+# e1 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = undef;
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no end in db, enclosing' );
+
+# ---s1---------------------
+# ---------s2-----e2--------
+# e1 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = undef;
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no end in db, completely after' );
+
+# ---------s1---------------
+# --------------------------
+# e1, s2, e2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = undef;
+$s2 = undef;
+$e2 = undef;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no end in db, no times in object' );
+
+# ---------s1---------------
+# -----s2-------------------
+# e1, e2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = undef;
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e2 = undef;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no end in db, start before, no end in object' );
+
+# ---------s1---------------
+# -------------s2-----------
+# e1, e2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = undef;
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$e2 = undef;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no end in db, start after, no end in object' );
+
+# ---------s1---------------
+# ---------s2---------------
+# e1, e2 undef
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = undef;
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e2 = undef;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: no end in db, same start' );
+
+# ---------s1---------------
+# ---e2---------------------
+# e1, s2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = undef;
+$s2 = undef;
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no end in db, no start in object, end before' );
+
+# ---------s1---------------
+# ---------------e2---------
+# e1, s2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = undef;
+$s2 = undef;
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no end in db, no start in object, end after' );
+
+# ---------s1---------------
+# ---------e2---------------
+# e1, s2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = undef;
+$s2 = undef;
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no end in db, no start in object, same end' );
+
+########################################
+# not overlapping if different staff_member
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 15, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 11, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping if same staff member' );
+$time_recordings[1]->update_attributes(staff_member => SL::DB::Employee->new(
+                                         'login' => 'testuser',
+                                         'name'  => 'Test User',
+                                       )->save);
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping if different staff member' );
+
+clear_up;
+
+1;
+
+
+# set emacs to perl mode
+# Local Variables:
+# mode: perl
+# End:
index 5050ecd..a4f6ddc 100644 (file)
@@ -98,6 +98,15 @@ sub new_invoice {
     %params,
   );
 }
+sub new_order {
+  my %params  = @_;
+
+  return create_sales_order(
+    transdate   => $transdate,
+    taxzone_id  => $taxzone->id,
+    %params,
+  );
+}
 
 sub new_item {
   my (%params) = @_;
@@ -109,6 +118,16 @@ sub new_item {
     %params,
   );
 }
+sub new_order_item {
+  my (%params) = @_;
+
+  my $part = delete($params{part}) || $parts[0];
+
+  return create_order_item(
+    part => $part,
+    %params,
+  );
+}
 
 sub test_default_invoice_one_item_19_tax_not_included() {
   reset_state();
@@ -553,6 +572,85 @@ sub test_default_invoice_one_item_19_tax_not_included_rounding_discount_big_qty_
     rounding                                    =>  0,
   }, "${title}: calculated data");
 }
+sub test_default_order_two_items_19_one_optional() {
+  reset_state();
+
+  my $item          = new_order_item(qty => 2.5);
+  my $item_optional = new_order_item(qty => 2.5, optional => 1);
+
+  my $order = new_order(
+    taxincluded  => 0,
+    orderitems => [ $item, $item_optional ],
+  );
+
+  my $taxkey = $item->part->get_taxkey(date => $transdate, is_sales => 1, taxzone => $order->taxzone_id);
+
+  # sellprice 2.34 * qty 2.5 = 5.85
+  # 19%(5.85) = 1.1115; rounded = 1.11
+  # total rounded = 6.96
+
+  # lastcost 1.93 * qty 2.5 = 4.825; rounded 4.83
+  # line marge_total = 1.02
+  # line marge_percent = 17.4358974358974
+
+  my $title = 'default order, two item, one item optional, 19% tax not included';
+  my %data  = $order->calculate_prices_and_taxes;
+
+  is($item->marge_total,        1.02,             "${title}: item marge_total");
+  is($item->marge_percent,      17.4358974358974, "${title}: item marge_percent");
+  is($item->marge_price_factor, 1,                "${title}: item marge_price_factor");
+
+  # optional items have a linetotal and marge, but ...
+  is($item_optional->marge_total,        1.02,             "${title}: item optional marge_total");
+  is($item_optional->marge_percent,      17.4358974358974, "${title}: item optional marge_percent");
+  is($item_optional->marge_price_factor, 1,                "${title}: item optional marge_price_factor");
+
+  # ... should not be calculated for the record sum
+  is($order->netamount,       5.85,             "${title}: netamount");
+  is($order->amount,          6.96,             "${title}: amount");
+  is($order->marge_total,     1.02,             "${title}: marge_total");
+  is($order->marge_percent,   17.4358974358974, "${title}: marge_percent");
+  is($order->orderitems->[1]->optional, 1,      "${title}: second order item has attribute optional");
+  # diag explain $order->orderitems->[1]->optional;
+  # diag explain \%data;
+  is_deeply(\%data, {
+    allocated                                    => {},
+    amounts                                      => {
+      $buchungsgruppe->income_accno_id($taxzone) => {
+        amount                                   => 5.85,
+        tax_id                                   => $tax->id,
+        taxkey                                   => 3,
+      },
+    },
+    amounts_cogs                                 => {},
+    assembly_items                               => [
+      [],
+      [],
+    ],
+    exchangerate                                 => 1,
+    taxes_by_chart_id                            => {
+      $tax->chart_id                             => 1.11,
+    },
+    taxes_by_tax_id                              => {
+      $tax->id                                   => 1.1115,
+    },
+    items                                        => [
+      { linetotal                                => 5.85,
+        linetotal_cost                           => 4.83,
+        sellprice                                => 2.34,
+        tax_amount                               => 1.1115,
+        taxkey_id                                => $taxkey->id,
+      },
+      { linetotal                                => 5.85,
+        linetotal_cost                           => 4.83,
+        sellprice                                => 2.34,
+        tax_amount                               => 1.1115,
+        taxkey_id                                => $taxkey->id,
+      },
+    ],
+    rounding                                    =>  0,
+  }, "${title}: calculated data");
+}
 
 
 Support::TestSetup::login();
@@ -566,6 +664,7 @@ test_default_invoice_three_items_sellprice_rounding_discount();
 test_default_invoice_one_item_19_tax_not_included_rounding_discount();
 test_default_invoice_one_item_19_tax_not_included_rounding_discount_huge_qty();
 test_default_invoice_one_item_19_tax_not_included_rounding_discount_big_qty_low_sellprice();
+test_default_order_two_items_19_one_optional();
 
 clear_up();
 done_testing();
index d838430..e7ade80 100644 (file)
@@ -56,6 +56,7 @@
 \newcommand{\auftragerteilt}{Auftrag erteilt:}
 \newcommand{\angebotortdatum}{Wir nehmen das vorstehende Angebot an.}
 \newcommand{\abweichendeLieferadresse}{abweichende Lieferadresse}
+\newcommand{\optional}{Optionale Position nach Absprache}
 
 % auftragbestätigung (sales_order)
 \newcommand{\auftragsbestaetigung} {Auftragsbestätigung}
index 326d041..a3736c4 100644 (file)
@@ -68,6 +68,7 @@
 \newcommand{\den} {Date}
 \newcommand{\unterschrift} {Signature}
 \newcommand{\stempel} {Company stamp}
+\newcommand{\optional}{Optional position by arrangement}
 
 % lieferschein (sales_delivery_order)
 \newcommand{\lieferschein} {Delivery order}
index ad0de3e..dfec85c 100644 (file)
           <%if serialnumber%> && \scriptsize \seriennummer: <%serialnumber%>\\<%end serialnumber%>
           <%if ean%> && \scriptsize \ean: <%ean%>\\<%end ean%>
           <%if projectnumber%> && \scriptsize \projektnummer: <%projectnumber%>\\<%end projectnumber%>
+          <%if optional%> && \scriptsize \optional \\<%end%>
           <%if customer_make%>
             <%foreach customer_make%>
               \ifthenelse{\equal{<%customer_make%>}{<%name%>}}{&& \kundenartnr: <%customer_model%>\\}{}
index 4010a7f..e8661cf 100644 (file)
           <%if serialnumber%> && \scriptsize \seriennummer: <%serialnumber%>\\<%end serialnumber%>
           <%if ean%> && \scriptsize \ean: <%ean%>\\<%end ean%>
           <%if projectnumber%> && \scriptsize \projektnummer: <%projectnumber%>\\<%end projectnumber%>
+          <%if optional%> && \scriptsize \optional \\<%end%>
           <%if customer_make%>
             <%foreach customer_make%>
               \ifthenelse{\equal{<%customer_make%>}{<%name%>}}{&& \kundenartnr: <%customer_model%>\\}{}
index 6e65c4d..6901cba 100644 (file)
         </td>
       </tr>
 
+     <tr>
+      <th align="right">[% 'Use date and duration for time recordings' | $T8 %]</th>
+      <td>
+        [% L.yes_no_tag('time_recording_use_duration', time_recording_use_duration) %]
+      </td>
+     </tr>
+
     </table>
    </div>
 
index 3cc28ef..2550a06 100644 (file)
     [% LxERP.t8('Any stock contents containing a best before date will be impossible to stock out otherwise.') %]
    </td>
   </tr>
+  <tr>
+   <td align="right">[% LxERP.t8('Undo Transfer Interval') %]</td>
+   <td>[% L.input_tag('defaults.unod_transfer_interval', LxERP.format_amount(SELF.defaults.undo_transfer_interval, 7), style=style) %]</td>
+   <td>[% LxERP.t8('Defines the interval where undoing transfers from a delivery order are allowed.') %]</td>
+  </tr>
+
  </table>
 </div>
index 9f03f7e..b58e762 100644 (file)
              files = FILES.part_files
              checked = INSTANCE_CONF.get_email_attachment_part_files_checked
              label = LxERP.t8("Files from parts") %]
+
+  [% PROCESS attach_file_list
+             files = FILES.project_files
+             label = LxERP.t8("Files from projects") %]
 [% END %]
  </tbody>
 </table>
index a33d9df..a03bcd2 100644 (file)
                             labeldx => LxERP.t8("Partsgroups where variables are shown")) %]
     </td>
    </tr>
+   <tr data-show-for="IC"[% UNLESS SELF.module == 'IC' %] style="display: none;"[% END %]>
+    <td align="right">[% 'Display in basic data tab' | $T8 %]</td>
+    <td>
+     [% L.radio_button_tag('config.first_tab', value='1', id='config.first_tab', label=LxERP.t8('Yes'), checked=(SELF.config.first_tab ?  1 : '')) %]
+     [% L.radio_button_tag('config.first_tab', value='0', id='config.first_tab', label=LxERP.t8('No'),  checked=(SELF.config.first_tab ? '' :  1)) %]
+    </td>
+   </tr>
+
   </table>
  </p>
 
index 08384f7..f8fa9d8 100644 (file)
        [% L.date_tag('bestbefore_'_ loop.count, row.bestbefore) %]
      </td>
      [% END %]
-     <td><input name="qty_[% loop.count %]" size="12" value="[% HTML.escape(LxERP.format_amount(row.qty)) %]"></td>
-
+     <td><input name="qty_[% loop.count %]" size="12"
+     [%- IF (!row.qty) && (loop.count == 1) %]
+       value="[% HTML.escape(do_qty) %]"
+     [%- ELSE %]
+       value="[% HTML.escape(LxERP.format_amount(row.qty)) %]"
+     [% END %]
+     ></td>
      <td>
       <select name="unit_[% loop.count %]">
        [%- FOREACH unit = UNITS %]
index fe90f4c..24da211 100644 (file)
@@ -70,7 +70,7 @@
                data-file-id="[% file.id %]" data-file-version="[% file.version %]"
                src="data:[% HTML.escape(file.thumbnail.thumbnail_img_content_type) %];base64,[% file.thumbnail.thumbnail_img_content.encode_base64 %]"
                alt="[% file.file_name %]">
-          <img id="enlarged_thumb_[% file.id %]" class="overlay_img">
+          <img id="enlarged_thumb_[% file.id %][% IF file.version %]_[% file.version %][% END %]" class="overlay_img">
          </div>
         [%- ELSE %]
          -
index c23c162..e61b175 100644 (file)
@@ -34,8 +34,9 @@
 [%- END %]
 [%- IF id %]
   [%- IF INSTANCE_CONF.get_doc_storage %]
-  <li><a href="controller.pl?action=File/list&file_type=document&object_type=invoice&object_id=[% HTML.url(id) %]">[% 'Documents' | $T8 %]</a></li>
-  <li><a href="controller.pl?action=File/list&file_type=attachment&object_type=invoice&object_id=[% HTML.url(id) %]">[% 'Attachments' | $T8 %]</a></li>
+  [% object_type = is_type_credit_note? 'credit_note' : 'invoice' %]
+  <li><a href="controller.pl?action=File/list&file_type=document&object_type=[% object_type %]&object_id=[% HTML.url(id) %]">[% 'Documents' | $T8 %]</a></li>
+  <li><a href="controller.pl?action=File/list&file_type=attachment&object_type=[% object_type %]&object_id=[% HTML.url(id) %]">[% 'Attachments' | $T8 %]</a></li>
   [%- END %]
   [%- IF AUTH.assert('record_links', 1) %]
   <li><a href="controller.pl?action=RecordLinks/ajax_list&object_model=Invoice&object_id=[% HTML.url(id) %]">[% 'Linked Records' | $T8 %]</a></li>
index 3d38a0d..922d89d 100644 (file)
   </tr>
 
   <tr>
-   <th valign="top" align="left">[% LxERP.t8("Number of deliveryorders created:") %]</th>
+   <th valign="top" align="left">[% LxERP.t8("Number of delivery orders created:") %]</th>
    <td valign="top">[% IF data.status > 0 %][% HTML.escape(data.num_created) %] / [% HTML.escape(data.record_ids.size) %][% ELSE %]–[% END %]</td>
   </tr>
 
   <tr>
-   <th valign="top" align="left">[% LxERP.t8("Number of deliveryorders printed:") %]</th>
+   <th valign="top" align="left">[% LxERP.t8("Number of delivery orders printed:") %]</th>
    <td valign="top">[% IF data.status > 1 %][% HTML.escape(data.num_printed) %] / [% HTML.escape(data.record_ids.size) %][% ELSE %]–[% END %]</td>
   </tr>
 
index 4be14f7..8b4d83a 100644 (file)
@@ -38,6 +38,9 @@
       <span[%- IF ITEM.part.onhand < ITEM.part.rop -%] class="numeric plus0"[%- END -%]>
         [%- ITEM.part.onhand_as_number -%]&nbsp;[%- ITEM.part.unit -%]
       </span>&nbsp;
+    <b>[%- 'Optional' | $T8 %]</b>&nbsp;
+      [%- L.yes_no_tag("order.orderitems[].optional", ITEM.optional
+                        class="recalc") %]&nbsp;
   </td></tr>
 
   <tr>
index e4d15c7..bc32cf8 100644 (file)
  <td></td>
  <td id="items_sellprice_sum" class="numeric">[%- LxERP.format_amount(items_sellprice_sum, 2, 0) %]</td>
  <td id="items_lastcost_sum"  class="numeric">[%- LxERP.format_amount(items_lastcost_sum,  2, 0) %]</td>
+ <td></td>
+</tr>
+<tr>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td align="right">[% 'Margepercent' | $T8 %]:</td>
+ <td></td>
+ <td class="numeric">
+ [% IF items_sellprice_sum > 0 %]
+   [%- LxERP.format_amount(100 - (items_lastcost_sum / items_sellprice_sum * 100), 2, 0) %]
+ [% END %]
+ </td>
+</tr>
+<tr>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td align="right">[% 'Margetotal' | $T8 %]:</td>
+ <td></td>
  <td id="items_sum_diff"      class="numeric">[%- LxERP.format_amount(items_sum_diff,      2, 0) %]</td>
 </tr>
 <tr>
index 90d5eec..47cd8f3 100644 (file)
                  <textarea id="part.formel" name="part.formel" rows="[% HTML.escape(notes_rows) %]" cols="30" wrap="soft" class="tooltipster-html" title="[% 'The formula needs the following syntax:<br>For regular article:<br>Variablename= Variable Unit;<br>Variablename2= Variable2 Unit2;<br>...<br>###<br>Variable + ( Variable2 / Variable )<br><b>Please be beware of the spaces in the formula</b><br>' | $T8 %]">[% HTML.escape(SELF.part.formel) %]</textarea>
                </td>
              </tr>
+             [% IF CUSTOM_VARIABLES_FIRST_TAB %]
+              <tr><td>[% 'Unchecked custom variables will not appear in orders and invoices.' | $T8 %]</td></tr>
+               [%- FOREACH var = CUSTOM_VARIABLES_FIRST_TAB %]
+               <tr>
+                <td align="left" valign="top">[% var.VALID_BOX %]
+                [%- IF !var.partsgroup_filtered %]
+                  [% HTML.escape(var.description) %]
+                [%- END %]
+               </tr>
+               <tr><td>[% var.HTML_CODE %]</td></tr>
+               [%- END %]
+             [% END %]
             </table>
            </td>
           </tr>
index 67df852..d5bb5b8 100644 (file)
     [%- IF SELF.may_edit_invoice_permissions %]
      <li><a href="#invoice_permissions">[% 'Permissions for invoices' | $T8 %]</a></li>
     [%- END %]
+    [%- IF SELF.project.id %]
+    <li><a href="#project_details">[% 'Project Details' | $T8 %]</a></li>
+      [%- IF INSTANCE_CONF.get_doc_storage %]
+        <li><a href="controller.pl?action=File/list&file_type=attachment&object_type=project&object_id=[% SELF.project.id %]">[% 'Attachments' | $T8 %]</a></li>
+      [%- END %]
+    [%- END %]
     [%- IF SELF.project.id and AUTH.assert('record_links', 1) %]
     <li><a href="#linked_records">[% 'Linked Records' | $T8 %]</a></li>
     [%- END %]
index 95c3c83..2580482 100644 (file)
 [% P.project.picker('project12_id', '', active='both', valid='both',style='width: 300px') %] all (active, inactive, valid, invalid)
 <br>
 
+<br>
+[% P.project.picker('project13_id', '', style='width: 300px') %] description style full (default)
+<br>
+
+<br>
+[% P.project.picker('project14_id', '', description_style='full', style='width: 300px') %] description style full (explicit)
+<br>
+
+<br>
+[% P.project.picker('project15_id', '', description_style='both', style='width: 300px') %] description style both
+<br>
+
+<br>
+[% P.project.picker('project16_id', '', description_style='number', style='width: 300px') %] description style number
+<br>
+
+<br>
+[% P.project.picker('project17_id', '', description_style='description', style='width: 300px') %] description style description
+<br>
+
+<br>
+
 Runtime test:<br>
 <div id='runtime_picker'>'
 
index bd9ac7a..4ed1ca8 100644 (file)
@@ -28,7 +28,6 @@
  [% RAW_TOP_INFO_TEXT %]
 
  [% IF DATA_PRESENT %]
- <p>
   <table [% IF TABLE_CLASS %]class="[% TABLE_CLASS %]"[% END %] id="report_table_id" width="100%">
    <thead>
    [%- FOREACH row = HEADER_ROWS %]
@@ -88,7 +87,6 @@
    </tbody>
   </table>
   <hr size="3" noshade>
- </p>
  [% ELSE %]
   <p class="message_hint">[% 'No data was found.' | $T8 %]</p>
  [% END %]
diff --git a/templates/webpages/shop_order/_get_one.html b/templates/webpages/shop_order/_get_one.html
new file mode 100644 (file)
index 0000000..6a368d3
--- /dev/null
@@ -0,0 +1,18 @@
+[%- USE HTML -%][%- USE LxERP -%][%- USE L -%][%- USE T8 -%]
+[% USE Dumper %]
+[% L.stylesheet_tag('webshop') %]
+[%- INCLUDE 'common/flash.html' %]
+<form id="get_one_order_form" action="controller.pl" method="post" style="padding-left:1em;">
+ <table>
+    <tr>
+     <th align="right">[% 'Shop' | $T8 %]</th>
+     <td>[% L.select_tag('shop_id', SELF.shops, value_key = 'value', title_key = 'title', default=1) %]</td>
+    </tr>
+    <tr>
+     <th align="right">[% 'Shop ordernumber' | $T8 %]</th>
+     <td>[% L.input_tag('shop_ordernumber', "") %]</td>
+    </tr>
+ </table>
+  [%  L.hidden_tag("action", "ShopOrder/dispatch") %]
+  [%  L.button_tag("kivi.ShopOrder.get_orders_one()", LxERP.t8('Fetch order')) %]
+</form>
index f30644c..e8c1034 100644 (file)
     [%- INCLUDE 'shop_order/_transfer_status.html' %]
   </div>
  </form>
+ <div id="get_one" style="display:none;">
+   [% INCLUDE 'shop_order/_get_one.html' %]
+ </div>
 <script type="text/javascript">
 <!--
 
diff --git a/templates/webpages/simple_system_setting/_time_recording_article_form.html b/templates/webpages/simple_system_setting/_time_recording_article_form.html
new file mode 100644 (file)
index 0000000..2c7ddf3
--- /dev/null
@@ -0,0 +1,11 @@
+[%- USE LxERP -%]
+[%- USE L -%]
+[%- USE P -%]
+<table>
+ <tr>
+  <th align="right">[% LxERP.t8("Article") %]</th>
+  <td>
+   [% P.part.picker('object.part_id', SELF.object.part, convertible_unit='min', "data-validate"="required", "data-title"=LxERP.t8("Article")) %]
+  </td>
+ </tr>
+</table>
diff --git a/templates/webpages/time_recording/_filter.html b/templates/webpages/time_recording/_filter.html
new file mode 100644 (file)
index 0000000..16fbe6d
--- /dev/null
@@ -0,0 +1,70 @@
+[%- USE T8 %]
+[%- USE L %]
+[%- USE P %]
+[%- USE LxERP %]
+[%- USE HTML %]
+<form action='controller.pl' method='post' id='filter_form'>
+<div class='filter_toggle'>
+<a href='#' onClick='javascript:$(".filter_toggle").toggle()'>[% 'Show Filter' | $T8 %]</a>
+  [% SELF.filter_summary | html %]
+</div>
+<div class='filter_toggle' style='display:none'>
+<a href='#' onClick='javascript:$(".filter_toggle").toggle()'>[% 'Hide Filter' | $T8 %]</a>
+ <table id='filter_table'>
+  <tr>
+   <th align="right">[% 'Date' | $T8 %] [% 'From Date' | $T8 %]</th>
+   <td>[% L.date_tag('filter.date:date::ge', filter.date_date__ge) %]</td>
+  </tr>
+  <tr>
+   <th align="right">[% 'Date' | $T8 %] [% 'To Date' | $T8 %]</th>
+   <td>[% L.date_tag('filter.date:date::le', filter.date_date__le) %]</td>
+  </tr>
+  <tr>
+    <th align="right">[% 'Customer' | $T8 %]</th>
+    <td>[% L.input_tag('filter.customer.name:substr::ilike', filter.customer.name_substr__ilike, size = 20) %]</td>
+  </tr>
+  <tr>
+    <th align="right">[% 'Customer Number' | $T8 %]</th>
+    <td>[% L.input_tag('filter.customer.customernumber:substr::ilike', filter.customer.customernumber_substr__ilike, size = 20) %]</td>
+  </tr>
+  <tr>
+    <th align="right">[% 'Order Number' | $T8 %]</th>
+    <td>[% L.input_tag('filter.order.ordnumber:substr::ilike', filter.order.ordnumber_substr__ilike, size = 20) %]</td>
+  </tr>
+  <tr>
+    <th align="right">[% 'Project' | $T8 %]</th>
+    <td>[% P.project.picker('filter.project_id', filter.project_id, active="both", valid="both", description_style='both', size = 20) %]</td>
+  </tr>
+  <tr>
+    <th align="right">[% 'Description' | $T8 %]</th>
+    <td>[% L.input_tag('filter.description:substr::ilike', filter.description_substr__ilike, size = 20) %]</td>
+  </tr>
+
+  [%- IF SELF.can_view_all -%]
+  <tr>
+   <th align="right">[% 'Mitarbeiter' | $T8 %]</th>
+   <td>
+     [% L.select_tag('filter.staff_member_id', SELF.all_employees,
+                     default    => filter.staff_member_id,
+                     title_key  => 'name',
+                     value_key  => 'id',
+                     with_empty => 1,
+                     style      => 'width: 200px') %]
+   </td>
+  </tr>
+  [%- END -%]
+
+  <tr>
+    <th align="right">[% 'Booked' | $T8 %]</th>
+    <td>[% L.select_tag('filter.booked', [ [ '1', LxERP.t8('Yes') ], [ '0', LxERP.t8('No') ] ], default=filter.booked, with_empty=1, style="width: 200px") %]</td>
+  </tr>
+
+ </table>
+
+[% L.hidden_tag('sort_by', FORM.sort_by) %]
+[% L.hidden_tag('sort_dir', FORM.sort_dir) %]
+[% L.hidden_tag('page', FORM.page) %]
+[% L.button_tag('$("#filter_form").clearForm()', LxERP.t8('Reset')) %]
+</div>
+
+</form>
diff --git a/templates/webpages/time_recording/form.html b/templates/webpages/time_recording/form.html
new file mode 100644 (file)
index 0000000..337f3c7
--- /dev/null
@@ -0,0 +1,95 @@
+[% USE L %]
+[% USE P %]
+[% USE T8 %]
+[% USE LxERP %]
+[% USE HTML %]
+
+<h1>[% title %]</h1>
+
+[%- INCLUDE 'common/flash.html' %]
+
+<form method="post" action="controller.pl" id="form">
+  [% P.hidden_tag('id',       SELF.time_recording.id) %]
+  [% L.hidden_tag('callback', FORM.callback) %]
+
+  <table>
+   [%- IF SELF.use_duration %]
+    <tr>
+      <th align="right">[% 'Date' | $T8 %]</th>
+      <td>
+        [% P.date_tag('time_recording.date_as_date', SELF.time_recording.date_as_date, "data-validate"="required", "data-title"=LxERP.t8('Date')) %]<br>
+      </td>
+    </tr>
+    <tr>
+      <th align="right">[% 'Duration' | $T8 %]</th>
+      <td>
+        [% P.input_tag('duration_h', SELF.time_recording.duration_as_hours   || '', size=4, class='numeric',
+           "data-validate"="number", "data-title"=LxERP.t8('h'),   "placeholder"=LxERP.format_amount(0.00, 2)) %] [% 'h'   | $T8 %]<sup>(1)</sup>
+        [% P.input_tag('duration_m', SELF.time_recording.duration_as_minutes || '', size=4, class='numeric',
+           "data-validate"="number", "data-title"=LxERP.t8('min'), "placeholder"="0"                         ) %] [% 'min' | $T8 %]
+      </td>
+    </tr>
+   [%- ELSE %]
+    <tr>
+      <th align="right">[% 'Start' | $T8 %]</th>
+      <td>
+        [% P.date_tag('start_date',  SELF.start_date, "data-validate"="required", "data-title"=LxERP.t8('Start date'), onchange='kivi.TimeRecording.set_end_date()') %]
+        [% P.input_tag('start_time', SELF.start_time, type="time", "data-validate"="required", "data-title"=LxERP.t8('Start time')) %]
+        [% P.button_tag('kivi.TimeRecording.set_current_date_time("start")', LxERP.t8('now')) %]
+      </td>
+    </tr>
+    <tr>
+      <th align="right">[% 'End' | $T8 %]</th>
+      <td>
+        [% P.date_tag('end_date',  SELF.end_date) %]
+        [% P.input_tag('end_time', SELF.end_time, type="time") %]
+        [% P.button_tag('kivi.TimeRecording.set_current_date_time("end")', LxERP.t8('now')) %]
+      </td>
+    </tr>
+   [%- END %]
+    <tr></tr><tr></tr>
+    <tr>
+      <th align="right">[% 'Sales Order' | $T8 %]</th>
+      <td>[% P.select_tag('time_recording.order_id', SELF.all_orders, default=SELF.time_recording.order_id, title_key='digest', with_empty=1, style='width: 300px', onchange='kivi.TimeRecording.order_changed(this.value)') %]</td>
+    </tr>
+    <tr>
+      <th align="right">[% 'Customer' | $T8 %]</th>
+      <td>[% P.customer_vendor.picker('time_recording.customer_id', SELF.time_recording.customer_id, type='customer', style='width: 300px', "data-validate"="required", "data-title"=LxERP.t8('Customer')) %]</td>
+    </tr>
+    <tr>
+      <th align="right">[% 'Project' | $T8 %]</th>
+      <td>[% P.project.picker('time_recording.project_id', SELF.time_recording.project_id, description_style='both', style='width: 300px', onchange='kivi.TimeRecording.project_changed()') %]</td>
+    </tr>
+    <tr></tr><tr></tr>
+    <tr>
+      <th align="right">[% 'Article' | $T8 %]</th>
+      <td>[% P.select_tag('time_recording.part_id', SELF.all_time_recording_articles, default=SELF.time_recording.part_id, with_empty=1, value_key='id', title_key='description', style='width: 300px') %]</td>
+    </tr>
+    <tr>
+      <th align="right">[% 'Description' | $T8 %]</th>
+      <td>[% L.textarea_tag('time_recording.description', SELF.time_recording.description, wrap="soft", style="width: 300px; height: 150px", class="texteditor", "data-validate"="required", "data-title"=LxERP.t8('Description')) %]</td>
+    </tr>
+    <tr>
+      <th align="right">[% 'Mitarbeiter' | $T8 %]</th>
+      <td>
+       [%- IF SELF.can_edit_all -%]
+        [% L.select_tag('time_recording.staff_member_id', SELF.all_employees,
+                        default    => SELF.time_recording.staff_member_id,
+                        title_key  => 'safe_name',
+                        value_key  => 'id',
+                        style      => 'width: 300px') %]
+       [%- ELSE -%]
+        [% SELF.time_recording.staff_member.safe_name | html %]
+       [%- END -%]
+      </td>
+    </tr>
+  </table>
+
+  [%- IF SELF.use_duration %]
+  <p>
+    <sup>(1)</sup>
+    [% 'Valid are integer values and floating point numbers, e.g. 4.75h = 4 hours and 45 minutes.' | $T8 %]
+  </p>
+  [%- END %]
+
+</form>
diff --git a/templates/webpages/time_recording/report_bottom.html b/templates/webpages/time_recording/report_bottom.html
new file mode 100644 (file)
index 0000000..a27818a
--- /dev/null
@@ -0,0 +1,9 @@
+[% USE HTML%]
+[%- USE T8 %]
+[%- USE L %][%- USE LxERP -%]
+ [% L.paginate_controls(models=SELF.models) %]
+ <input type="hidden" name="rowcount" value="[% HTML.escape(rowcount) %]">
+ [%- FOREACH item = HIDDEN %]
+ <input type="hidden" name="[% HTML.escape(item.key) %]" value="[% HTML.escape(item.value) %]">
+ [%- END %]
+</form>
diff --git a/templates/webpages/time_recording/report_top.html b/templates/webpages/time_recording/report_top.html
new file mode 100644 (file)
index 0000000..9541d80
--- /dev/null
@@ -0,0 +1,2 @@
+[%- PROCESS 'time_recording/_filter.html' filter=SELF.models.filtered.laundered %]
+<hr>
index 8400452..def92cd 100644 (file)
        <tr>
         <td align="right"><input name="l_employee" id="l_employee" class="checkbox" type="checkbox" value="Y"></td>
         <td nowrap><label for="l_employee">[% 'Employee' | $T8 %]</label></td>
-        <td align="right"><input name="l_oe_id" id="l_oe_id" class="checkbox" type="checkbox" value="Y"></td>
+        <td align="right"><input name="l_oe_id" id="l_oe_id" class="checkbox" type="checkbox" value="Y" checked></td>
         <td nowrap><label for="l_oe_id">[% 'Document' | $T8 %]</label></td>
         <td align="right"><input name="l_projectnumber" id="l_projectnumber" class="checkbox" type="checkbox" value="Y" checked></td>
         <td nowrap><label for="l_projectnumber">[% 'Project Number' | $T8 %]</label></td>
index 9d79535..c1dea3f 100644 (file)
          [% L.radio_button_tag("stock_value_basis", value='list_price',     checked=0, label=LxERP.t8('List Price')) %]
         </td>
        </tr>
+       <tr>
         <th align="right">
           [% "List all rows" | $T8 %]:
         </th>
          [% L.yes_no_tag("allrows", 1) %]
         </td>
        </tr>
-
+       <tr>
+        <th align="right">
+          [% "Results per page" | $T8 %]:
+        </th>
+        <td align="left">
+         [% L.input_number_tag("per_page", 20, size=4) %]
+        </td>
+       </tr>
       </table>
      </td>
     </tr>
         <td align="right"><input name="l_list_price" id="l_list_price" class="checkbox" type="checkbox" value="Y"></td>
         <td nowrap><label for="l_list_price">[% 'List Price' | $T8 %]</label></td>
        </tr>
+       [% IF INCLUDABLE_CVAR_CONFIGS %]
+         <tr><td colspan="6"><hr noshade height="1"></td></tr>
+         [% FOREACH cvar_cfg = INCLUDABLE_CVAR_CONFIGS %]
+         <tr>
+          <td colspan="2" align="left">
+           [% name__ = cvar_cfg.name;
+            L.checkbox_tag("l_cvar_" _ name__, value="1", checked=(cvar_cfg.included_by_default ? 1 : ''), label=cvar_cfg.description) %]
+          </td>
+         </tr>
+         [% END %]
+       [% END %]
       </table>
      </td>
     </tr>