]> wagnertech.de Git - mfinanz.git/commitdiff
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 b7c60033b9c7a8280c238e4757d147e958591371..57e6b445abf44c388e3bc682401b98401f929dc8 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 1d044eaade50ffc191e660df824a39cab7bf185a..a2d786400bf267332098c8a7609cc494881f00f6 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 938e62eab33a539d7fc18f96c458e6c3928d7184..2e637cd1fea793f1a23e35894239a628843b880d 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 9c0e58f68e569818069fe1ffe1e1607677d69555..6151ec158581d46c8bc6fe8e0221063a6b1a91bb 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 7d8c362c907ed4da8a0fc9565e0d1f0bea8c448c..49bde8092cbfbeb1114c5969ad9f93786a51a04f 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 bb1e8d6681cfdb6789756000755906212d75e5f0..05043d9d3d96ba705bb364701b2087c7bfd4e3ba 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 8b5b5e7023e3743c15ff1c6c4a45374581c51d05..77063738c63c3a59113f565db731b0510c1ffaa5 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 663be5eb8b8e7beb1ebf78b51578e3f315c307a7..fe8af336ab047a01d420181eabf9d3f67a52e891 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 5168ae28c8c0edbd8ca736e1910c20ec947d760b..2632722adf41e4f762019663decfa8d5012d8b35 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 0f2b5294dc00994407e3bb77543779f081f99f67..2f8b15c06314c09d013c05a43ebca2e47e9edb1c 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 238abf33add0cca6565f087e86d7ab48f1f3d957..c724fb8fc96cca4604001b0909d6b4f9e385b23b 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 ecd212cc88f156278256c718c22a3c9518a6008c..9afaf637c4a0649e1fed108bdf324eb3cc0c8afd 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 4b846dc27cef68418244ab1ef134de8643f48f1e..4fea748c5c9dca5f3d95ee0467ab65285013cc7f 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 b4d9e004138ac22d8702b62f50dfea7e74b1853a..1ca25ad8649b79e1e10fc2e7cb974b0c2d9ec070 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 fdfded2769f6dc7c123d86daeb735e360848df9f..9005d79d621ade9ba238c28cf639918ccd1e02d4 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 15df1582a57480cf594d57eb8e30ce1aaa5e6c33..5a7d58eb8ba55bc0ad9b52106ef9a4d74fc4922c 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 cfc94eea14a0355d0a32bd49b41007e3e5258375..0b476ea815ecb990c830e3a2f039288135af0c0a 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 72f9bd4b2e15ed893a0b353f1f452e7af389de2c..94862e11b98f16a0a234feadadd155536f181378 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 715f8834f309c563da1a2782c018cb54897f3a51..2da4a41dae7d9fed38560adf0376f3994310dfd7 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 f8dd4bc494b745fec6995b6ddec90abeca756be8..463d74fb3390294132e5adaf4e20302d27b3518a 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 15eeee0c5d1c61e4303dd70cb6594e5a8937cc51..395a44944621ec4d40f2d4d6081cfbc178246bfa 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 006a389db3820802bb831d76e37d0d08e60512e6..e0ab57845c0aeddb75aa62ceb14fe79a7fb8f16a 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 ccd6ca045b10e138d1e236d6d698fafc499457dc..f99993922f718edbf917ab8f00930f5749545842 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 da931f9c893c41b42a7739aec56dd523d74d3728..94f7e474493a43b2a191c62b6d333f4a4c956c42 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 15850d558946b1bb748f4a6d14bb5af333f9cba8..bde711a797a97fc0a3995566a7701e61686bc34e 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 af6d69084f41b8cea66186ee513398a9b0026e02..684d2291706789bcf5cf683636cfb1cf732fa518 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 c998dc4d4f1151122076999e174dd300c843b5e9..6308292d7cea4a0c1be07e7aaae78523688e8f29 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 16478b1498e780e2bf176d589683bed2c39b777a..afa64d815e61b5b0167c4cf7bc00efaf3ff47294 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 dc4bbebd85a31378a939ec55860c8a2704e7c7c8..b3741df989d9bd6f67ee6e96d618e83d1ca3902e 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 684c49b287bc8bc4661236ee798a5211f5de236b..2752de6271a32ad896811bfea4ace1871c114207 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 90599a7de193e4b06685fc23d542c1badb856edd..3b0b41be24a44750b87a3129cc3b6a0e18040b4f 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 b702b0f5b24dca73775c0ae689866fae65086095..8bf30a55196918c76e7de9e371a624cef0474e2f 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 2a61422c82f883a2a6870b7fd0fb88db84bad1ff..921cb253ef452fc884e8220db702479d91c9796a 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 35b5d6e0843a696ff8ee40178d2c812722eb5e42..4a7b2452ec70e9ae3f404fe8aec67e7a41f891db 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 5ddf2c8625fcfed11dc0a48230e1d0b6f3bb67c8..f36ca04855b952fb96481f7002e756c0dc1ea840 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 5fe66cf47465c924d6329d7e9fc962a6899724b6..7cf80080589132fb40f169925b27889b358bf92b 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 aeac4e59da18b0792d80d3d3efbf311210009e65..fedc58898c24c500040bed84aa197982c90d474c 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 1b640e37bc39aa6f1ac2393b79085c36698c410e..6b4831267aa64a57c257d8aff3bab70450e17b85 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 af40c30cf261a5803b3d9acf35cda5af315b08d7..8aecdfe492d9692b1fd405c7e9c273088ba4faec 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 75180a91c16b5ab9a13b78587bcd5961ebe81241..275e2e33a1ca80165824f314bf51504bdccad863 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 19a712f86a0252a5d7fb2f7311240118a635ca0b..56127e4c460a20e75a39728feabace6c729c5adc 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 fdfd141266699ec9481d191d0a6838c52e9afd12..938f99b17cf8f06c89336d43620ccac2c70ea477 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 d0415618717701f7910e3d82d7067b12620eeaa7..0b35fc7d9e4f3920805c23a7d2f3189190688ebd 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 51fbb1aa1cccb29cefd89b28947a102bf28d33c5..d88cb2d14645b62a71e53800b7884f0938ac90c7 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 fc38c52ad7fa78e541a1040edee685ab690e4627..4f55310aa987a249958cb08a9462b9411fa52b91 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 79e50859ebcfa430b7dff39792fe1fbf4b53cdfc..16aaf775a768d0b917b64b7760873677136b3b21 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 0a6b861d0d6b90aaa88269fdc14b8a9dd85ad1e8..7c7fa49d6cd53ff5da3929025bd5ccaed7f278f1 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 48ff6c3b085059e61ff59bbcc17fd7f882652988..da168a2286a9d10f2fbc24d455a11582fee62fd1 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 254d016eea61ecbbb8905384239fe176797cac8b..2f3803b885fdb49a70be8c3812e275c5469f3623 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 a7f6edeac0f756e43f88caf20409c7742d881732..f6db5e2e4c65e99693bcbef0882ce2f36e02a4f9 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 8c5e5f2d22a7831d1dc627b95618e33db98842e5..2e812f31f1333cb52fd180ba2665906a69d59fe7 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 f3b2839b2a1783e71810f14e67cba341425f02c7..ee47ba4b37394f2b9ccd22c6ac719ec4ba30cd08 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 aba682153f26f6df6ea3b3a6e212fd1ced0482cb..6a566c444d28f5a9cbcd8adac3940fc38834c561 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 8645a09c5a0c92b78a51dfec68e061318ff767e0..6b4ea462ceb62c09b5f0bb843eefb20d06a548a4 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 714a40e53d3f90b5e9a85c3b314df6ec72919d9f..5459c0be2525e5914af213e63238ba81f249de90 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 2b63a1a33178bbc19e4ee8738045c619aab4f384..98a538c194d97896430c9417cb5d4b95e7ee2ad3 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 21ac4873ce194a57d034960c3456279b51519a43..467fcbd89eaa39d0c1806f1665385f6f5b186bd9 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 bc19ee537f4dcd7888c1a1806b3161764155d473..b564ffb78940b94ac683f9bf51efd424228692f1 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 9b0153dbdfcf3c0327b7658893d2c305a17b7c55..0a7766931eb0fabf848f73a29e9fb9c02793055d 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 a089e68a1a0d7196646b0818e62ccbb1d69d0788..2d3885a9d1a09e832a154e2af527a4a5de684d1c 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 66440a0bc372e7cc2a38eb652a7b80fd57a65668..e4c39b99a8b65891f7bc58b5dd9d06990ba82bba 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 5d39813f54368418d4ad83a231836bbafb49d3f7..204237ed7647280096b30d6d66856ad5e9c390ad 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 5050ecd5ff290467e11a8891649f3805508c588e..a4f6ddcaffe7afa22bae239b0792903658f9d51c 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 d8384302fb4474141967cd2a2fc4132fbd348795..e7ade80a3127f4ce50213f28ae36a33521f34bad 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 326d0411f5e0eb81e4ab4570ff4d631f55511b28..a3736c4bd734dad2ec3fdd97c64a1f3d1f705f4d 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 ad0de3eb8e6c50b2c4d43bbb54b344e674b2eec6..dfec85cb17f4ca08a740183d1b946c909a46ba4b 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 4010a7fa32602a9f170edbb4f5ca30dc6ec2d3bc..e8661cf2e1628ac5659c3b720239ccafd6a83fb7 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 6e65c4d571e589cef9108a9cf3b8f72e4fa472e2..6901cba8cd4cafe95250213e660e3fa7985dacbd 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 3cc28efa07c765e9c240402045b59a1f1e548e83..2550a06034856c2957a9faa4ca5cb2ad74b624be 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 9f03f7e5fab120d80adf81e91d573a47067f45f7..b58e762c47b42d0f499ac40371c0c49bb5e07ab7 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 a33d9dfb1834340da9b46792e5d8e60c77380d11..a03bcd26ebfe1336b3d59f1cba6ead48fd04a7d6 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 08384f76d663f81190fb7cb04b609e133955d023..f8fa9d852d1b56c2763c2423e69746cb7d9078a7 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 fe90f4c3700a549054ebd1e67235354415ba9062..24da211796fc435f6f3d8a4529d5a0bf6a58b616 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 c23c162294ac58f131e64b062faf937d1e2c02fb..e61b175e83750de3417d1913658fb86586b60553 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 3d38a0d2a35d7e53185542718fa89f0e0e24e81b..922d89d7deefba883292a95c3128872833787419 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 4be14f7f109434ef02584973e7f30abb86b6322a..8b4d83ad6fcb292b0ef616ad2ef5831391244ad2 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 e4d15c7fd408acb0db6e3c805c437ded97de57ac..bc32cf8999ee1914b99ab2ed5c78a2c099174573 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 90d5eeceb106c56ba76eb02c32b87ef6278270f9..47cd8f3e85a660403276cd1fba09f8dd97a03ce4 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 67df852e4ef8cbe7fe373356bcb4c2912fb5acb1..d5bb5b887826003432cf366c0a96a7baaa1add25 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 95c3c833fdce4394da473386eaf5123b82aa5d9d..25804827407284413877fb1c78a9f86296a64701 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 bd9ac7a514a2da6028ec96351df499e8d7822b56..4ed1ca88e7d95f34e943f0eacfc1b745807404fd 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 f30644c96bcb23f540d07d4f203ca4330cb7e344..e8c10340cd4ebb7e07cef13cbd3a41c102cec0af 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 8400452a862d90fe93e28b19df59b8c6004f1b24..def92cd2c688bd2158275c69ec060e1f1b8691b2 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 9d79535c6db972522e6d314eb4186496089477ac..c1dea3f6a57193eeb5a75e5861f95875f26cd9f2 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>