From: Bernd Bleßmann Date: Mon, 10 May 2021 18:58:04 +0000 (+0200) Subject: Merge pull request #30 from rebootl/csv-import-script-fix X-Git-Tag: kivitendo-mebil_0.1-0~9^2~238 X-Git-Url: http://wagnertech.de/gitweb/gitweb.cgi/mfinanz.git/commitdiff_plain/5202b3e71b817c6a78845cd4c27773760ff408b6?hp=1eb1e1cfe46fcea908c46f275fbb46fd7810fcbf Merge pull request #30 from rebootl/csv-import-script-fix CSV Import Shell Script parameter ergänzt sowie Ausgabeprüfung behoben --- diff --git a/SL/AM.pm b/SL/AM.pm index b7c60033b..57e6b445a 100644 --- 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(); diff --git a/SL/ARAP.pm b/SL/ARAP.pm index 1d044eaad..a2d786400 100644 --- a/SL/ARAP.pm +++ b/SL/ARAP.pm @@ -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; diff --git a/SL/BackgroundJob/ALL.pm b/SL/BackgroundJob/ALL.pm index 938e62eab..2e637cd1f 100644 --- a/SL/BackgroundJob/ALL.pm +++ b/SL/BackgroundJob/ALL.pm @@ -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 index 000000000..ff8c79b61 --- /dev/null +++ b/SL/BackgroundJob/ConvertTimeRecordings.pm @@ -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 +Cnew_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 + +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 + +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 + +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 + +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 + +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 + +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 + +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 + +Use this project id instead of the project id in the time recordings to find +a related order. This is only used if C is true. + +=item C + +Use this project id if no project id is set in the time recording +entry. This is only used if C 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 Ebernd@kivitendo-premium.deE + +=cut diff --git a/SL/BackgroundJob/CreatePeriodicInvoices.pm b/SL/BackgroundJob/CreatePeriodicInvoices.pm index 9c0e58f68..6151ec158 100644 --- a/SL/BackgroundJob/CreatePeriodicInvoices.pm +++ b/SL/BackgroundJob/CreatePeriodicInvoices.pm @@ -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; diff --git a/SL/ClientJS.pm b/SL/ClientJS.pm index 7d8c362c9..49bde8092 100644 --- a/SL/ClientJS.pm +++ b/SL/ClientJS.pm @@ -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! diff --git a/SL/Controller/CsvImport.pm b/SL/Controller/CsvImport.pm index bb1e8d668..05043d9d3 100644 --- a/SL/Controller/CsvImport.pm +++ b/SL/Controller/CsvImport.pm @@ -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} || [] }; diff --git a/SL/Controller/CsvImport/BankTransaction.pm b/SL/Controller/CsvImport/BankTransaction.pm index 8b5b5e702..77063738c 100644 --- a/SL/Controller/CsvImport/BankTransaction.pm +++ b/SL/Controller/CsvImport/BankTransaction.pm @@ -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; diff --git a/SL/Controller/CsvImport/Base.pm b/SL/Controller/CsvImport/Base.pm index 663be5eb8..fe8af336a 100644 --- a/SL/Controller/CsvImport/Base.pm +++ b/SL/Controller/CsvImport/Base.pm @@ -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 { diff --git a/SL/Controller/CsvImport/BaseMulti.pm b/SL/Controller/CsvImport/BaseMulti.pm index 5168ae28c..2632722ad 100644 --- a/SL/Controller/CsvImport/BaseMulti.pm +++ b/SL/Controller/CsvImport/BaseMulti.pm @@ -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 }; diff --git a/SL/Controller/CsvImport/CustomerVendor.pm b/SL/Controller/CsvImport/CustomerVendor.pm index 0f2b5294d..2f8b15c06 100644 --- a/SL/Controller/CsvImport/CustomerVendor.pm +++ b/SL/Controller/CsvImport/CustomerVendor.pm @@ -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); diff --git a/SL/Controller/CsvImport/Inventory.pm b/SL/Controller/CsvImport/Inventory.pm index 238abf33a..c724fb8fc 100644 --- a/SL/Controller/CsvImport/Inventory.pm +++ b/SL/Controller/CsvImport/Inventory.pm @@ -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; diff --git a/SL/Controller/CsvImport/Part.pm b/SL/Controller/CsvImport/Part.pm index ecd212cc8..9afaf637c 100644 --- a/SL/Controller/CsvImport/Part.pm +++ b/SL/Controller/CsvImport/Part.pm @@ -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'); diff --git a/SL/Controller/CsvImport/Project.pm b/SL/Controller/CsvImport/Project.pm index 4b846dc27..4fea748c5 100644 --- a/SL/Controller/CsvImport/Project.pm +++ b/SL/Controller/CsvImport/Project.pm @@ -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; diff --git a/SL/Controller/CsvImport/Shipto.pm b/SL/Controller/CsvImport/Shipto.pm index b4d9e0041..1ca25ad86 100644 --- a/SL/Controller/CsvImport/Shipto.pm +++ b/SL/Controller/CsvImport/Shipto.pm @@ -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; diff --git a/SL/Controller/File.pm b/SL/Controller/File.pm index fdfded276..9005d79d6 100644 --- a/SL/Controller/File.pm +++ b/SL/Controller/File.pm @@ -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') diff --git a/SL/Controller/Order.pm b/SL/Controller/Order.pm index 15df1582a..5a7d58eb8 100644 --- a/SL/Controller/Order.pm +++ b/SL/Controller/Order.pm @@ -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 = diff --git a/SL/Controller/Part.pm b/SL/Controller/Part.pm index cfc94eea1..0b476ea81 100644 --- a/SL/Controller/Part.pm +++ b/SL/Controller/Part.pm @@ -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'), diff --git a/SL/Controller/Project.pm b/SL/Controller/Project.pm index 72f9bd4b2..94862e11b 100644 --- a/SL/Controller/Project.pm +++ b/SL/Controller/Project.pm @@ -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); diff --git a/SL/Controller/ShopOrder.pm b/SL/Controller/ShopOrder.pm index 715f8834f..2da4a41da 100644 --- a/SL/Controller/ShopOrder.pm +++ b/SL/Controller/ShopOrder.pm @@ -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 => [ diff --git a/SL/Controller/SimpleSystemSetting.pm b/SL/Controller/SimpleSystemSetting.pm index f8dd4bc49..463d74fb3 100644 --- a/SL/Controller/SimpleSystemSetting.pm +++ b/SL/Controller/SimpleSystemSetting.pm @@ -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 index 000000000..f87c4d24d --- /dev/null +++ b/SL/Controller/TimeRecording.pm @@ -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 '
', @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; diff --git a/SL/DB/DeliveryOrder.pm b/SL/DB/DeliveryOrder.pm index 15eeee0c5..395a44944 100644 --- a/SL/DB/DeliveryOrder.pm +++ b/SL/DB/DeliveryOrder.pm @@ -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} .= '
  • ' . $new_description . '
  • ' + 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 . ' ' . _format_total($entry->{duration}) . ' h'; + $longdescription .= '
      '; + $longdescription .= $entry->{content}; + $longdescription .= '
    '; + } + + 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 + +Creates a new C 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 instances. + +C<%params> can include the following options: + +=over 2 + +=item C + +An optional hash reference. If it exists then it is used to set +attributes of the newly created delivery order object. + +=item C + +An optional part id which is used as default value if no part is set +in the time recording entry. + +=item C + +Like C but given as partnumber, not as id. + +=item C + +An optional part id which is used instead of a value set in the time +recording entry. + +=item C + +Like C but given as partnumber, not as id. + +=item C + +An optional C object. If it exists then it is used to +generate the delivery order from that via C. +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 + +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 TODO: Describe sales_order diff --git a/SL/DB/Helper/ALL.pm b/SL/DB/Helper/ALL.pm index 006a389db..e0ab57845 100644 --- a/SL/DB/Helper/ALL.pm +++ b/SL/DB/Helper/ALL.pm @@ -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; diff --git a/SL/DB/Helper/FlattenToForm.pm b/SL/DB/Helper/FlattenToForm.pm index ccd6ca045..f99993922 100644 --- a/SL/DB/Helper/FlattenToForm.pm +++ b/SL/DB/Helper/FlattenToForm.pm @@ -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)); diff --git a/SL/DB/Helper/Mappings.pm b/SL/DB/Helper/Mappings.pm index da931f9c8..94f7e4744 100644 --- a/SL/DB/Helper/Mappings.pm +++ b/SL/DB/Helper/Mappings.pm @@ -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', diff --git a/SL/DB/Helper/PriceTaxCalculator.pm b/SL/DB/Helper/PriceTaxCalculator.pm index 15850d558..bde711a79 100644 --- a/SL/DB/Helper/PriceTaxCalculator.pm +++ b/SL/DB/Helper/PriceTaxCalculator.pm @@ -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 index 000000000..edb6d1bd0 --- /dev/null +++ b/SL/DB/Manager/TimeRecording.pm @@ -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 index 000000000..9048b6188 --- /dev/null +++ b/SL/DB/Manager/TimeRecordingArticle.pm @@ -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; diff --git a/SL/DB/MetaSetup/CustomVariableConfig.pm b/SL/DB/MetaSetup/CustomVariableConfig.pm index af6d69084..684d22917 100644 --- a/SL/DB/MetaSetup/CustomVariableConfig.pm +++ b/SL/DB/MetaSetup/CustomVariableConfig.pm @@ -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 }, diff --git a/SL/DB/MetaSetup/Default.pm b/SL/DB/MetaSetup/Default.pm index c998dc4d4..6308292d7 100644 --- a/SL/DB/MetaSetup/Default.pm +++ b/SL/DB/MetaSetup/Default.pm @@ -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' }, diff --git a/SL/DB/MetaSetup/OrderItem.pm b/SL/DB/MetaSetup/OrderItem.pm index 16478b149..afa64d815 100644 --- a/SL/DB/MetaSetup/OrderItem.pm +++ b/SL/DB/MetaSetup/OrderItem.pm @@ -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 index 000000000..a5001312d --- /dev/null +++ b/SL/DB/MetaSetup/TimeRecording.pm @@ -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 index 000000000..5d7bd8412 --- /dev/null +++ b/SL/DB/MetaSetup/TimeRecordingArticle.pm @@ -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; +; diff --git a/SL/DB/Order.pm b/SL/DB/Order.pm index dc4bbebd8..b3741df98 100644 --- a/SL/DB/Order.pm +++ b/SL/DB/Order.pm @@ -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, ); diff --git a/SL/DB/ShopOrder.pm b/SL/DB/ShopOrder.pm index 684c49b28..2752de627 100644 --- a/SL/DB/ShopOrder.pm +++ b/SL/DB/ShopOrder.pm @@ -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 index 000000000..13329b782 --- /dev/null +++ b/SL/DB/TimeRecording.pm @@ -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 index 000000000..63483786c --- /dev/null +++ b/SL/DB/TimeRecordingArticle.pm @@ -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; diff --git a/SL/DO.pm b/SL/DO.pm index 90599a7de..3b0b41be2 100644 --- 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(); diff --git a/SL/Dev/ALL.pm b/SL/Dev/ALL.pm index b702b0f5b..8bf30a551 100644 --- a/SL/Dev/ALL.pm +++ b/SL/Dev/ALL.pm @@ -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 index 000000000..825472f81 --- /dev/null +++ b/SL/Dev/TimeRecording.pm @@ -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 => '

    this and that

    ', + staff_member => $staff_member, + employee => $employee, + %params, + ); + + return $time_recording; +} + + +1; diff --git a/SL/Form.pm b/SL/Form.pm index 2a61422c8..921cb253e 100644 --- a/SL/Form.pm +++ b/SL/Form.pm @@ -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') diff --git a/SL/Helper/ISO3166.pm b/SL/Helper/ISO3166.pm index 35b5d6e08..4a7b2452e 100644 --- a/SL/Helper/ISO3166.pm +++ b/SL/Helper/ISO3166.pm @@ -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 index 000000000..7686a7cd5 --- /dev/null +++ b/SL/Helper/UserPreferences/TimeRecording.pm @@ -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 Ebernd@kivitendo-premium.deE + +=cut diff --git a/SL/IC.pm b/SL/IC.pm index 5ddf2c862..f36ca0485 100644 --- 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} }; diff --git a/SL/IS.pm b/SL/IS.pm index 5fe66cf47..7cf800805 100644 --- 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; } diff --git a/SL/InstallationCheck.pm b/SL/InstallationCheck.pm index aeac4e59d..fedc58898 100644 --- a/SL/InstallationCheck.pm +++ b/SL/InstallationCheck.pm @@ -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' }, diff --git a/SL/OE.pm b/SL/OE.pm index 1b640e37b..6b4831267 100644 --- 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) { diff --git a/SL/Presenter/Project.pm b/SL/Presenter/Project.pm index af40c30cf..8aecdfe49 100644 --- a/SL/Presenter/Project.pm +++ b/SL/Presenter/Project.pm @@ -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, diff --git a/SL/ReportGenerator.pm b/SL/ReportGenerator.pm index 75180a91c..275e2e33a 100644 --- a/SL/ReportGenerator.pm +++ b/SL/ReportGenerator.pm @@ -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. diff --git a/SL/ShopConnector/Base.pm b/SL/ShopConnector/Base.pm index 19a712f86..56127e4c4 100644 --- a/SL/ShopConnector/Base.pm +++ b/SL/ShopConnector/Base.pm @@ -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 +=item C =item C @@ -50,6 +52,8 @@ __END__ =item C +=item C + =back =head1 SEE ALSO diff --git a/SL/ShopConnector/Shopware.pm b/SL/ShopConnector/Shopware.pm index fdfd14126..938f99b17 100644 --- a/SL/ShopConnector/Shopware.pm +++ b/SL/ShopConnector/Shopware.pm @@ -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 + +Fetches one order specified by ordnumber + =item C +Fetches new order by parameters from shop configuration + =item C Creates on shoporder object from json diff --git a/bin/mozilla/am.pl b/bin/mozilla/am.pl index d04156187..0b35fc7d9 100644 --- a/bin/mozilla/am.pl +++ b/bin/mozilla/am.pl @@ -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(); diff --git a/bin/mozilla/ar.pl b/bin/mozilla/ar.pl index 51fbb1aa1..d88cb2d14 100644 --- a/bin/mozilla/ar.pl +++ b/bin/mozilla/ar.pl @@ -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} })); diff --git a/bin/mozilla/do.pl b/bin/mozilla/do.pl index fc38c52ad..4f55310aa 100644 --- a/bin/mozilla/do.pl +++ b/bin/mozilla/do.pl @@ -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; diff --git a/bin/mozilla/gl.pl b/bin/mozilla/gl.pl index 79e50859e..16aaf775a 100644 --- a/bin/mozilla/gl.pl +++ b/bin/mozilla/gl.pl @@ -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)); diff --git a/bin/mozilla/io.pl b/bin/mozilla/io.pl index 0a6b861d0..7c7fa49d6 100644 --- a/bin/mozilla/io.pl +++ b/bin/mozilla/io.pl @@ -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 = diff --git a/bin/mozilla/ir.pl b/bin/mozilla/ir.pl index 48ff6c3b0..da168a228 100644 --- a/bin/mozilla/ir.pl +++ b/bin/mozilla/ir.pl @@ -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 => [ diff --git a/bin/mozilla/oe.pl b/bin/mozilla/oe.pl index 254d016ee..2f3803b88 100644 --- a/bin/mozilla/oe.pl +++ b/bin/mozilla/oe.pl @@ -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 diff --git a/bin/mozilla/rp.pl b/bin/mozilla/rp.pl index a7f6edeac..f6db5e2e4 100644 --- a/bin/mozilla/rp.pl +++ b/bin/mozilla/rp.pl @@ -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(); diff --git a/bin/mozilla/wh.pl b/bin/mozilla/wh.pl index 8c5e5f2d2..2e812f31f 100644 --- a/bin/mozilla/wh.pl +++ b/bin/mozilla/wh.pl @@ -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; diff --git a/doc/changelog b/doc/changelog index f3b2839b2..ee47ba4b3 100644 --- a/doc/changelog +++ b/doc/changelog @@ -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 diff --git a/js/autocomplete_project.js b/js/autocomplete_project.js index aba682153..6a566c444 100644 --- a/js/autocomplete_project.js +++ b/js/autocomplete_project.js @@ -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; } diff --git a/js/kivi.File.js b/js/kivi.File.js index 8645a09c5..6b4ea462c 100644 --- a/js/kivi.File.js +++ b/js/kivi.File.js @@ -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 }; diff --git a/js/kivi.Order.js b/js/kivi.Order.js index 714a40e53..5459c0be2 100644 --- a/js/kivi.Order.js +++ b/js/kivi.Order.js @@ -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', diff --git a/js/kivi.SalesPurchase.js b/js/kivi.SalesPurchase.js index 2b63a1a33..98a538c19 100644 --- a/js/kivi.SalesPurchase.js +++ b/js/kivi.SalesPurchase.js @@ -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) { diff --git a/js/kivi.ShopOrder.js b/js/kivi.ShopOrder.js index 21ac4873c..467fcbd89 100644 --- a/js/kivi.ShopOrder.js +++ b/js/kivi.ShopOrder.js @@ -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 index 000000000..47b0f16cc --- /dev/null +++ b/js/kivi.TimeRecording.js @@ -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); + } + }); + }; + +}); diff --git a/js/kivi.Validator.js b/js/kivi.Validator.js index bc19ee537..b564ffb78 100644 --- a/js/kivi.Validator.js +++ b/js/kivi.Validator.js @@ -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', diff --git a/js/locale/de.js b/js/locale/de.js index 9b0153dbd..0a7766931 100644 --- a/js/locale/de.js +++ b/js/locale/de.js @@ -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", diff --git a/js/locale/en.js b/js/locale/en.js index a089e68a1..2d3885a9d 100644 --- a/js/locale/en.js +++ b/js/locale/en.js @@ -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":"", diff --git a/locale/de/all b/locale/de/all index 66440a0bc..e4c39b99a 100755 --- a/locale/de/all +++ b/locale/de/all @@ -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.
    Reason:
    #1' => 'Kann nicht ein-/auslagern.
    Grund:
    #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', diff --git a/locale/en/all b/locale/en/all index 5d39813f5..204237ed7 100644 --- a/locale/en/all +++ b/locale/en/all @@ -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.
    Reason:
    #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 index 000000000..eab685f05 --- /dev/null +++ b/menus/user/10-time-recording.yaml @@ -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 index 000000000..b8e0b939d --- /dev/null +++ b/sql/Pg-upgrade2-auth/right_time_recording.sql @@ -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 index 000000000..24453a937 --- /dev/null +++ b/sql/Pg-upgrade2-auth/rights_time_recording_show_edit_all.sql @@ -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 index 000000000..79e2abab3 --- /dev/null +++ b/sql/Pg-upgrade2/add_transfer_doc_interval.sql @@ -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 index 000000000..efb420d17 --- /dev/null +++ b/sql/Pg-upgrade2/custom_variables_add_edit_position.sql @@ -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 index 000000000..31e82328c --- /dev/null +++ b/sql/Pg-upgrade2/cvars_remove_dublicate_entries.pl @@ -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 index 000000000..4ad74e4c4 --- /dev/null +++ b/sql/Pg-upgrade2/file_storage_project.sql @@ -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 index 000000000..3b4ddda5d --- /dev/null +++ b/sql/Pg-upgrade2/orderitems_optional.sql @@ -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 index 000000000..ccbf329cc --- /dev/null +++ b/sql/Pg-upgrade2/time_recordings.sql @@ -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 index 000000000..afebb212a --- /dev/null +++ b/sql/Pg-upgrade2/time_recordings2.sql @@ -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 index 000000000..a9b1a0d9a --- /dev/null +++ b/sql/Pg-upgrade2/time_recordings_add_order.sql @@ -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 index 000000000..c693c3699 --- /dev/null +++ b/sql/Pg-upgrade2/time_recordings_articles.sql @@ -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 index 000000000..98404e162 --- /dev/null +++ b/sql/Pg-upgrade2/time_recordings_date_duration.sql @@ -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 index 000000000..db79bba7c --- /dev/null +++ b/sql/Pg-upgrade2/time_recordings_remove_type.sql @@ -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 index 000000000..454af117a --- /dev/null +++ b/t/background_job/convert_time_recordings.t @@ -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 index 000000000..2eb987e53 --- /dev/null +++ b/t/controllers/csvimport/customervendor.t @@ -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 = \< '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 = \< '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 = \< '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 = \<[0])->name, 'CustomerName', 'simple file - name only (customer)'; + +$entries = undef; +clear_up; + +##### +$file = \<[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 = \<[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 = \<[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 = \<[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 = \< '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 = \< '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 index 000000000..33b036484 --- /dev/null +++ b/t/db/time_recordig.t @@ -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: diff --git a/t/db_helper/price_tax_calculator.t b/t/db_helper/price_tax_calculator.t index 5050ecd5f..a4f6ddcaf 100644 --- a/t/db_helper/price_tax_calculator.t +++ b/t/db_helper/price_tax_calculator.t @@ -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(); diff --git a/templates/print/RB/deutsch.tex b/templates/print/RB/deutsch.tex index d8384302f..e7ade80a3 100644 --- a/templates/print/RB/deutsch.tex +++ b/templates/print/RB/deutsch.tex @@ -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} diff --git a/templates/print/RB/english.tex b/templates/print/RB/english.tex index 326d0411f..a3736c4bd 100644 --- a/templates/print/RB/english.tex +++ b/templates/print/RB/english.tex @@ -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} diff --git a/templates/print/RB/sales_order.tex b/templates/print/RB/sales_order.tex index ad0de3eb8..dfec85cb1 100644 --- a/templates/print/RB/sales_order.tex +++ b/templates/print/RB/sales_order.tex @@ -151,6 +151,7 @@ <%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%>\\}{} diff --git a/templates/print/RB/sales_quotation.tex b/templates/print/RB/sales_quotation.tex index 4010a7fa3..e8661cf2e 100644 --- a/templates/print/RB/sales_quotation.tex +++ b/templates/print/RB/sales_quotation.tex @@ -147,6 +147,7 @@ <%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%>\\}{} diff --git a/templates/webpages/am/config.html b/templates/webpages/am/config.html index 6e65c4d57..6901cba8c 100644 --- a/templates/webpages/am/config.html +++ b/templates/webpages/am/config.html @@ -87,6 +87,13 @@ + + [% 'Use date and duration for time recordings' | $T8 %] + + [% L.yes_no_tag('time_recording_use_duration', time_recording_use_duration) %] + + + diff --git a/templates/webpages/client_config/_warehouse.html b/templates/webpages/client_config/_warehouse.html index 3cc28efa0..2550a0603 100644 --- a/templates/webpages/client_config/_warehouse.html +++ b/templates/webpages/client_config/_warehouse.html @@ -116,5 +116,11 @@ [% LxERP.t8('Any stock contents containing a best before date will be impossible to stock out otherwise.') %] + + [% LxERP.t8('Undo Transfer Interval') %] + [% L.input_tag('defaults.unod_transfer_interval', LxERP.format_amount(SELF.defaults.undo_transfer_interval, 7), style=style) %] + [% LxERP.t8('Defines the interval where undoing transfers from a delivery order are allowed.') %] + + diff --git a/templates/webpages/common/_send_email_dialog.html b/templates/webpages/common/_send_email_dialog.html index 9f03f7e5f..b58e762c4 100644 --- a/templates/webpages/common/_send_email_dialog.html +++ b/templates/webpages/common/_send_email_dialog.html @@ -103,6 +103,10 @@ 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 %] diff --git a/templates/webpages/custom_variable_config/form.html b/templates/webpages/custom_variable_config/form.html index a33d9dfb1..a03bcd26e 100644 --- a/templates/webpages/custom_variable_config/form.html +++ b/templates/webpages/custom_variable_config/form.html @@ -97,6 +97,14 @@ labeldx => LxERP.t8("Partsgroups where variables are shown")) %] + + [% 'Display in basic data tab' | $T8 %] + + [% 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)) %] + + +

    diff --git a/templates/webpages/do/stock_in_form.html b/templates/webpages/do/stock_in_form.html index 08384f76d..f8fa9d852 100644 --- a/templates/webpages/do/stock_in_form.html +++ b/templates/webpages/do/stock_in_form.html @@ -140,8 +140,13 @@ [% L.date_tag('bestbefore_'_ loop.count, row.bestbefore) %] [% END %] - - + + [% IF CUSTOM_VARIABLES_FIRST_TAB %] + [% 'Unchecked custom variables will not appear in orders and invoices.' | $T8 %] + [%- FOREACH var = CUSTOM_VARIABLES_FIRST_TAB %] + + [% var.VALID_BOX %] + [%- IF !var.partsgroup_filtered %] + [% HTML.escape(var.description) %] + [%- END %] + + [% var.HTML_CODE %] + [%- END %] + [% END %] diff --git a/templates/webpages/project/form.html b/templates/webpages/project/form.html index 67df852e4..d5bb5b887 100644 --- a/templates/webpages/project/form.html +++ b/templates/webpages/project/form.html @@ -20,6 +20,12 @@ [%- IF SELF.may_edit_invoice_permissions %]
  • [% 'Permissions for invoices' | $T8 %]
  • [%- END %] + [%- IF SELF.project.id %] +
  • [% 'Project Details' | $T8 %]
  • + [%- IF INSTANCE_CONF.get_doc_storage %] +
  • [% 'Attachments' | $T8 %]
  • + [%- END %] + [%- END %] [%- IF SELF.project.id and AUTH.assert('record_links', 1) %]
  • [% 'Linked Records' | $T8 %]
  • [%- END %] diff --git a/templates/webpages/project/test_page.html b/templates/webpages/project/test_page.html index 95c3c833f..258048274 100644 --- a/templates/webpages/project/test_page.html +++ b/templates/webpages/project/test_page.html @@ -46,6 +46,28 @@ [% P.project.picker('project12_id', '', active='both', valid='both',style='width: 300px') %] all (active, inactive, valid, invalid)
    +
    +[% P.project.picker('project13_id', '', style='width: 300px') %] description style full (default) +
    + +
    +[% P.project.picker('project14_id', '', description_style='full', style='width: 300px') %] description style full (explicit) +
    + +
    +[% P.project.picker('project15_id', '', description_style='both', style='width: 300px') %] description style both +
    + +
    +[% P.project.picker('project16_id', '', description_style='number', style='width: 300px') %] description style number +
    + +
    +[% P.project.picker('project17_id', '', description_style='description', style='width: 300px') %] description style description +
    + +
    + Runtime test:
    ' diff --git a/templates/webpages/report_generator/html_report.html b/templates/webpages/report_generator/html_report.html index bd9ac7a51..4ed1ca88e 100644 --- a/templates/webpages/report_generator/html_report.html +++ b/templates/webpages/report_generator/html_report.html @@ -28,7 +28,6 @@ [% RAW_TOP_INFO_TEXT %] [% IF DATA_PRESENT %] -

    [%- FOREACH row = HEADER_ROWS %] @@ -88,7 +87,6 @@


    -

    [% ELSE %]

    [% 'No data was found.' | $T8 %]

    [% END %] diff --git a/templates/webpages/shop_order/_get_one.html b/templates/webpages/shop_order/_get_one.html new file mode 100644 index 000000000..6a368d3df --- /dev/null +++ b/templates/webpages/shop_order/_get_one.html @@ -0,0 +1,18 @@ +[%- USE HTML -%][%- USE LxERP -%][%- USE L -%][%- USE T8 -%] +[% USE Dumper %] +[% L.stylesheet_tag('webshop') %] +[%- INCLUDE 'common/flash.html' %] +
    + + + + + + + + + +
    [% 'Shop' | $T8 %][% L.select_tag('shop_id', SELF.shops, value_key = 'value', title_key = 'title', default=1) %]
    [% 'Shop ordernumber' | $T8 %][% L.input_tag('shop_ordernumber', "") %]
    + [% L.hidden_tag("action", "ShopOrder/dispatch") %] + [% L.button_tag("kivi.ShopOrder.get_orders_one()", LxERP.t8('Fetch order')) %] +
    diff --git a/templates/webpages/shop_order/list.html b/templates/webpages/shop_order/list.html index f30644c96..e8c10340c 100644 --- a/templates/webpages/shop_order/list.html +++ b/templates/webpages/shop_order/list.html @@ -192,6 +192,9 @@ [%- INCLUDE 'shop_order/_transfer_status.html' %]
    +