1 package SL::BackgroundJob::ConvertTimeRecordings;
 
   5 use parent qw(SL::BackgroundJob::Base);
 
   7 use SL::DB::DeliveryOrder;
 
  10 use SL::DB::TimeRecording;
 
  11 use SL::Helper::ShippedQty;
 
  12 use SL::Locale::String qw(t8);
 
  15 use List::Util qw(any);
 
  18   $_[0]->create_standard_job('7 3 1 * *'); # every first day of month at 03:07
 
  20 use Rose::Object::MakeMethods::Generic (
 
  21  'scalar'                => [ qw(params) ],
 
  25 # If job does not throw an error,
 
  26 # success in background_job_histories is 'success'.
 
  27 # It is 'failure' otherwise.
 
  29 # Return value goes to result in background_job_histories.
 
  32   my ($self, $db_obj) = @_;
 
  34   $self->initialize_params($db_obj->data_as_hash) if $db_obj;
 
  36   $self->{$_} = [] for qw(job_errors);
 
  39   %customer_where = ('customer_id' => $self->params->{customer_ids}) if scalar @{ $self->params->{customer_ids} };
 
  41   my $time_recordings = SL::DB::Manager::TimeRecording->get_all(where => [date        => { ge_lt => [ $self->params->{from_date}, $self->params->{to_date} ]},
 
  42                                                                           or          => [booked => 0, booked => undef],
 
  47   return t8('No time recordings to convert') if scalar @$time_recordings == 0;
 
  51   if ($self->params->{link_order}) {
 
  52     my %time_recordings_by_order_id;
 
  53     my %orders_by_order_id;
 
  54     foreach my $tr (@$time_recordings) {
 
  55       my $order = $self->get_order_for_time_recording($tr);
 
  57       push @{ $time_recordings_by_order_id{$order->id} }, $tr;
 
  58       $orders_by_order_id{$order->id} ||= $order;
 
  60     @donumbers = $self->convert_with_linking(\%time_recordings_by_order_id, \%orders_by_order_id);
 
  63     @donumbers = $self->convert_without_linking($time_recordings);
 
  66   my $msg  = t8('Number of delivery orders created:');
 
  68   $msg    .= scalar @donumbers;
 
  70   $msg    .= join ', ', @donumbers;
 
  72   # die if errors exists
 
  73   if (@{ $self->{job_errors} }) {
 
  74     $msg  .= ' ' . t8('The following errors occurred:');
 
  76     $msg  .= join "\n", @{ $self->{job_errors} };
 
  83 sub initialize_params {
 
  84   my ($self, $data) = @_;
 
  86   # valid parameters with default values
 
  88     from_date           => DateTime->new( day => 1,    month => DateTime->today_local->month, year => DateTime->today_local->year)->subtract(months => 1)->to_kivitendo,
 
  89     to_date             => DateTime->last_day_of_month(month => DateTime->today_local->month, year => DateTime->today_local->year)->subtract(months => 1)->to_kivitendo,
 
  90     customernumbers     => [],
 
  91     override_part_id    => undef,
 
  92     default_part_id     => undef,
 
  93     override_project_id => undef,
 
  94     default_project_id  => undef,
 
 100   # check user input param names
 
 101   foreach my $param (keys %$data) {
 
 102     die "Not a valid parameter: $param" unless exists $valid_params{$param};
 
 107     { map { ($_ => $data->{$_} // $valid_params{$_}) } keys %valid_params }
 
 111   # convert date from string to object
 
 112   my ($from_date, $to_date);
 
 114     if ($self->params->{from_date}) {
 
 115       $from_date = DateTime->from_kivitendo($self->params->{from_date});
 
 116       # no undef and no other type.
 
 117       die unless ref $from_date eq 'DateTime';
 
 119     if ($self->params->{to_date}) {
 
 120       $to_date = DateTime->from_kivitendo($self->params->{to_date});
 
 121       # no undef and no other type.
 
 122       die unless ref $to_date eq 'DateTime';
 
 125     die t8("Cannot convert date.") ."\n" .
 
 126         t8("Input from string: #1", $self->params->{from_date}) . "\n" .
 
 127         t8("Input to string: #1", $self->params->{to_date}) . "\n" .
 
 128         t8("Details: #1", $_);
 
 131   $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)
 
 133   $self->params->{from_date} = $from_date;
 
 134   $self->params->{to_date}   = $to_date;
 
 137   # check if customernumbers are valid
 
 138   die 'Customer numbers must be given in an array' if 'ARRAY' ne ref $self->params->{customernumbers};
 
 141   if (scalar @{ $self->params->{customernumbers} }) {
 
 142     $customers = SL::DB::Manager::Customer->get_all(where => [ customernumber => $self->params->{customernumbers},
 
 143                                                                or             => [obsolete => undef, obsolete => 0] ]);
 
 145   die 'Not all customer numbers are valid' if scalar @$customers != scalar @{ $self->params->{customernumbers} };
 
 147   # return customer ids
 
 148   $self->params->{customer_ids} = [ map { $_->id } @$customers ];
 
 152   if ($self->params->{override_part_id} && !SL::DB::Manager::Part->find_by(id => $self->params->{override_part_id},
 
 153                                                                            or => [obsolete => undef, obsolete => 0])) {
 
 154     die 'No valid part found by given override part id';
 
 156   if ($self->params->{default_part_id} && !SL::DB::Manager::Part->find_by(id => $self->params->{default_part_id},
 
 157                                                                            or => [obsolete => undef, obsolete => 0])) {
 
 158     die 'No valid part found by given default part id';
 
 163   if ($self->params->{override_project_id} && !SL::DB::Manager::Project->find_by(id => $self->params->{override_project_id},
 
 164                                                                                  active => 1, valid => 1)) {
 
 165     die 'No valid project found by given override project id';
 
 167   if ($self->params->{default_project_id} && !SL::DB::Manager::Project->find_by(id => $self->params->{default_project_id},
 
 168                                                                                 active => 1, valid => 1)) {
 
 169     die 'No valid project found by given default project id';
 
 172   return $self->params;
 
 175 sub convert_without_linking {
 
 176   my ($self, $time_recordings) = @_;
 
 178   my %time_recordings_by_customer_id;
 
 179   push @{ $time_recordings_by_customer_id{$_->customer_id} }, $_ for @$time_recordings;
 
 181   my %convert_params = (
 
 182     rounding         => $self->params->{rounding},
 
 183     override_part_id => $self->params->{override_part_id},
 
 184     default_part_id  => $self->params->{default_part_id},
 
 188   foreach my $customer_id (keys %time_recordings_by_customer_id) {
 
 191       $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_customer_id{$customer_id}, %convert_params);
 
 194       $self->log_error("creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
 
 198       if (!SL::DB->client->with_transaction(sub {
 
 200         $_->update_attributes(booked => 1) for @{$time_recordings_by_customer_id{$customer_id}};
 
 203         $self->log_error('saving delivery order failed for time recording ids ' . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
 
 205         push @donumbers, $do->donumber;
 
 213 sub convert_with_linking {
 
 214   my ($self, $time_recordings_by_order_id, $orders_by_order_id) = @_;
 
 216   my %convert_params = (
 
 217     rounding        => $self->params->{rounding},
 
 218     override_part_id => $self->params->{override_part_id},
 
 219     default_part_id  => $self->params->{default_part_id},
 
 223   foreach my $related_order_id (keys %$time_recordings_by_order_id) {
 
 224     my $related_order = $orders_by_order_id->{$related_order_id};
 
 227       $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_order_id->{$related_order_id}, related_order => $related_order, %convert_params);
 
 230       $self->log_error("creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
 
 234       if (!SL::DB->client->with_transaction(sub {
 
 236         $_->update_attributes(booked => 1) for @{$time_recordings_by_order_id->{$related_order_id}};
 
 238         $related_order->link_to_record($do);
 
 240         # TODO extend link_to_record for items, otherwise long-term no d.r.y.
 
 241         foreach my $item (@{ $do->items }) {
 
 242           foreach (qw(orderitems)) {
 
 243             if ($item->{"converted_from_${_}_id"}) {
 
 244               die unless $item->{id};
 
 245               RecordLinks->create_links('mode'       => 'ids',
 
 247                                         'from_ids'   => $item->{"converted_from_${_}_id"},
 
 248                                         'to_table'   => 'delivery_order_items',
 
 249                                         'to_id'      => $item->{id},
 
 251               delete $item->{"converted_from_${_}_id"};
 
 256         # update delivered and item's ship for related order
 
 257         my $helper = SL::Helper::ShippedQty->new->calculate($related_order)->write_to_objects;
 
 258         $related_order->delivered($related_order->{delivered});
 
 259         $_->ship($_->{shipped_qty}) for @{$related_order->items};
 
 260         $related_order->save(cascade => 1);
 
 264         $self->log_error('saving delivery order failed for time recording ids ' . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
 
 267         push @donumbers, $do->donumber;
 
 275 sub get_order_for_time_recording {
 
 276   my ($self, $tr) = @_;
 
 280   if (!$tr->order_id) {
 
 283     $project_id   = $self->params->{override_project_id};
 
 284     $project_id ||= $tr->project_id;
 
 285     $project_id ||= $self->params->{default_project_id};
 
 288       $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no project id');
 
 292     my $project = SL::DB::Project->load_cached($project_id);
 
 295       $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project not found');
 
 298     if (!$project->active || !$project->valid) {
 
 299       $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project not active or not valid');
 
 302     if ($project->customer_id && $project->customer_id != $tr->customer_id) {
 
 303       $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project customer does not match customer of time recording');
 
 307     $orders = SL::DB::Manager::Order->get_all(where        => [customer_id      => $tr->customer_id,
 
 308                                                                or               => [quotation => undef, quotation => 0],
 
 309                                                                globalproject_id => $project_id, ],
 
 310                                               with_objects => ['orderitems']);
 
 314     my $order = SL::DB::Manager::Order->find_by(id => $tr->order_id);
 
 315     push @$orders, $order if $order;
 
 318   if (!scalar @$orders) {
 
 319     $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no order found');
 
 325   $part_id   = $self->params->{override_part_id};
 
 326   $part_id ||= $tr->part_id;
 
 327   $part_id ||= $self->params->{default_part_id};
 
 330     $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no part id');
 
 333   my $part = SL::DB::Part->load_cached($part_id);
 
 334   if (!$part->unit_obj->is_time_based) {
 
 335     $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : part unit is not time based');
 
 340   foreach my $order (@$orders) {
 
 341     if (any { $_->parts_id == $part_id } @{ $order->items_sorted }) {
 
 342       push @matching_orders, $order;
 
 346   if (1 != scalar @matching_orders) {
 
 347     $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no or more than one orders do match');
 
 351   my $matching_order = $matching_orders[0];
 
 353   if (!$matching_order->is_sales) {
 
 354     $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : found order is not a sales order');
 
 358   if ($matching_order->customer_id != $tr->customer_id) {
 
 359     $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : customer of order does not match customer of time recording');
 
 363   if ($tr->project_id && !$self->params->{override_project_id} && $tr->project_id != ($matching_order->globalproject_id || 0)) {
 
 364     $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project of order does not match project of time recording');
 
 368   return $matching_order;
 
 372   my ($self, $msg) = @_;
 
 376   push @{ $self->{job_errors} }, $msg;
 
 377   $::lxdebug->message(LXDebug->WARN(), 'ConvertTimeRecordings: ' . $msg) if $dbg;
 
 390 SL::BackgroundJob::ConvertTimeRecordings - Convert time recording
 
 391 entries into delivery orders
 
 395 Get all time recording entries for the given period and customer numbers
 
 396 and create delivery ordes out of that (using
 
 397 C<SL::DB::DeliveryOrder-E<gt>new_from_time_recordings>).
 
 401 Some data can be provided to configure this backgroung job.
 
 402 If there is user data and it cannot be validated the background job
 
 407   from_date: 01.12.2020
 
 409   customernumbers: [1,2,3]
 
 415 The date from which on time recordings should be collected. It defaults
 
 416 to the first day of the previous month.
 
 418 Example (format depends on your settings):
 
 420 from_date: 01.12.2020
 
 424 The date till which time recordings should be collected. It defaults
 
 425 to the last day of the previous month.
 
 427 Example (format depends on your settings):
 
 431 =item C<customernumbers>
 
 433 An array with the customer numbers for which time recordings should
 
 434 be collected. If not given, time recordings for all customers are
 
 437 customernumbers: [c1,22332,334343]
 
 439 =item C<override_part_id>
 
 441 The part id of a time based service which should be used to
 
 442 book the times instead of the parts which are set in the time
 
 445 =item C<default_part_id>
 
 447 The part id of a time based service which should be used to
 
 448 book the times if no part is set in the time recording entry.
 
 452 If set the 0 no rounding of the times will be done otherwise
 
 453 the times will be rounded up to the full quarters of an hour,
 
 454 ie. 0.25h 0.5h 0.75h 1.25h ...
 
 455 Defaults to rounding true (1).
 
 459 If set the job links the created delivery order with the order
 
 460 given in the time recording entry. If there is no order given, then
 
 461 it tries to find an order with the current customer and project
 
 462 number. It tries to do as much automatic workflow processing as the
 
 464 Defaults to off. If set to true (1) the job will fail if there
 
 465 is no sales order which qualifies as a predecessor.
 
 466 Conditions for a predeccesor:
 
 468  * Order given in time recording entry OR
 
 469  * Global project_id must match time_recording.project_id OR data.project_id
 
 470  * Customer must match customer in time recording entry
 
 471  * The sales order must have at least one or more time related services
 
 472  * The Project needs to be valid and active
 
 474 The job doesn't care if the sales order is already delivered or closed.
 
 475 If the sales order is overdelivered some organisational stuff needs to be done.
 
 476 The sales order may also already be closed, ie the amount is fully billed, but
 
 477 the services are not yet fully delivered (simple case: 'Payment in advance').
 
 479 Hint: take a look or extend the job CloseProjectsBelongingToClosedSalesOrder for
 
 480 further automatisation of your organisational needs.
 
 482 =item C<override_project_id>
 
 484 Use this project id instead of the project id in the time recordings to find
 
 485 a related order. This is only used if C<link_order> is true.
 
 487 =item C<default_project_id>
 
 489 Use this project id if no project id is set in the time recording
 
 490 entry. This is only used if C<link_order> is true.
 
 498 =item * part and project parameters as numbers
 
 500 Add parameters to give part and project not with their ids, but with their
 
 501 numbers. E.g. (default_/override_)part_number,
 
 502 (default_/override_)project_number.
 
 509 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>