X-Git-Url: http://wagnertech.de/git?a=blobdiff_plain;ds=sidebyside;f=SL%2FBackgroundJob%2FConvertTimeRecordings.pm;h=45ded55e83da02c432df22973feae9e5c03368b9;hb=11aeaa07186197c61dfe610d2a5c981754fd2e9d;hp=8f6a32949f38913d0060a35a6866ba75b3107c94;hpb=719fd0838e9631d7397d4b2ca4303dd85c4b26a3;p=kivitendo-erp.git diff --git a/SL/BackgroundJob/ConvertTimeRecordings.pm b/SL/BackgroundJob/ConvertTimeRecordings.pm index 8f6a32949..45ded55e8 100644 --- a/SL/BackgroundJob/ConvertTimeRecordings.pm +++ b/SL/BackgroundJob/ConvertTimeRecordings.pm @@ -5,17 +5,21 @@ 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); use Try::Tiny; - 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, @@ -27,46 +31,167 @@ sub create_job { sub run { my ($self, $db_obj) = @_; - my $data; - $data = $db_obj->data_as_hash if $db_obj; + $self->initialize_params($db_obj->data_as_hash) if $db_obj; $self->{$_} = [] for qw(job_errors); - # from/to date from data. Defaults to begining and end of last month. - my $from_date; - my $to_date; - # handle errors with a catch handler + + 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, $to_date); try { - $from_date = DateTime->from_kivitendo($data->{from_date}) if $data->{from_date}; - $to_date = DateTime->from_kivitendo($data->{to_date}) if $data->{to_date}; + if ($self->params->{from_date}) { + $from_date = DateTime->from_kivitendo($self->params->{from_date}); + # no undef and no other type. + die unless ref $from_date eq 'DateTime'; + } + if ($self->params->{to_date}) { + $to_date = DateTime->from_kivitendo($self->params->{to_date}); + # no undef and no other type. + die unless ref $to_date eq 'DateTime'; + } } catch { - die "Cannot convert date from string $data->{from_date} $data->{to_date}\n Details :\n $_"; # not $@ + die t8("Cannot convert date.") ."\n" . + t8("Input from string: #1", $self->params->{from_date}) . "\n" . + t8("Input to string: #1", $self->params->{to_date}) . "\n" . + t8("Details: #1", $_); }; - $from_date ||= DateTime->new( day => 1, month => DateTime->today_local->month, year => DateTime->today_local->year)->subtract(months => 1); - $to_date ||= DateTime->last_day_of_month(month => DateTime->today_local->month, year => DateTime->today_local->year)->subtract(months => 1); $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) - my %customer_where; - %customer_where = ('customer.customernumber' => $data->{customernumbers}) if 'ARRAY' eq ref $data->{customernumbers}; + $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 = SL::DB::Manager::TimeRecording->get_all(where => [end_time => { ge_lt => [ $from_date, $to_date ]}, - or => [booked => 0, booked => undef], - %customer_where], - with_objects => ['customer']); 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}); + $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_customer_id{$customer_id}, %convert_params); 1; }) { - $::lxdebug->message(LXDebug->WARN(), - "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}}); - push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}}; - + $self->log_error("creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}}); } if ($do) { @@ -75,36 +200,185 @@ sub run { $_->update_attributes(booked => 1) for @{$time_recordings_by_customer_id{$customer_id}}; 1; })) { - $::lxdebug->message(LXDebug->WARN(), - "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}}); - push @{ $self->{job_errors} }, "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}}; + $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; } } } - 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 .= join "\n", @{ $self->{job_errors} }; - die $msg; + 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 $msg; + + 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; -# possible data -# from_date: 01.12.2020 -# to_date: 15.12.2020 -# customernumbers: [1,2,3] __END__ =pod @@ -125,6 +399,14 @@ 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 @@ -149,13 +431,77 @@ 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 customers are -collected. This is the default. - -Example (format depends on your settings): +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