ff8c79b6137d97d8f381b3175ae91b5fb0eacad7
[kivitendo-erp.git] / SL / BackgroundJob / ConvertTimeRecordings.pm
1 package SL::BackgroundJob::ConvertTimeRecordings;
2
3 use strict;
4
5 use parent qw(SL::BackgroundJob::Base);
6
7 use SL::DB::DeliveryOrder;
8 use SL::DB::Part;
9 use SL::DB::Project;
10 use SL::DB::TimeRecording;
11 use SL::Helper::ShippedQty;
12 use SL::Locale::String qw(t8);
13
14 use DateTime;
15 use List::Util qw(any);
16
17 sub create_job {
18   $_[0]->create_standard_job('7 3 1 * *'); # every first day of month at 03:07
19 }
20 use Rose::Object::MakeMethods::Generic (
21  'scalar'                => [ qw(params) ],
22 );
23
24 #
25 # If job does not throw an error,
26 # success in background_job_histories is 'success'.
27 # It is 'failure' otherwise.
28 #
29 # Return value goes to result in background_job_histories.
30 #
31 sub run {
32   my ($self, $db_obj) = @_;
33
34   $self->initialize_params($db_obj->data_as_hash) if $db_obj;
35
36   $self->{$_} = [] for qw(job_errors);
37
38   my %customer_where;
39   %customer_where = ('customer_id' => $self->params->{customer_ids}) if scalar @{ $self->params->{customer_ids} };
40
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],
43                                                                           '!duration' => 0,
44                                                                           '!duration' => undef,
45                                                                           %customer_where]);
46
47   return t8('No time recordings to convert') if scalar @$time_recordings == 0;
48
49   my @donumbers;
50
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);
56       next if !$order;
57       push @{ $time_recordings_by_order_id{$order->id} }, $tr;
58       $orders_by_order_id{$order->id} ||= $order;
59     }
60     @donumbers = $self->convert_with_linking(\%time_recordings_by_order_id, \%orders_by_order_id);
61
62   } else {
63     @donumbers = $self->convert_without_linking($time_recordings);
64   }
65
66   my $msg  = t8('Number of delivery orders created:');
67   $msg    .= ' ';
68   $msg    .= scalar @donumbers;
69   $msg    .= ' (';
70   $msg    .= join ', ', @donumbers;
71   $msg    .= ').';
72   # die if errors exists
73   if (@{ $self->{job_errors} }) {
74     $msg  .= ' ' . t8('The following errors occurred:');
75     $msg  .= ' ';
76     $msg  .= join "\n", @{ $self->{job_errors} };
77     die $msg . "\n";
78   }
79   return $msg;
80 }
81
82 # helper
83 sub initialize_params {
84   my ($self, $data) = @_;
85
86   # valid parameters with default values
87   my %valid_params = (
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,
95     rounding            => 1,
96     link_order          => 0,
97   );
98
99
100   # check user input param names
101   foreach my $param (keys %$data) {
102     die "Not a valid parameter: $param" unless exists $valid_params{$param};
103   }
104
105   # set defaults
106   $self->params(
107     { map { ($_ => $data->{$_} // $valid_params{$_}) } keys %valid_params }
108   );
109
110
111   # convert date from string to object
112   my $from_date;
113   my $to_date;
114   $from_date = DateTime->from_kivitendo($self->params->{from_date});
115   $to_date   = DateTime->from_kivitendo($self->params->{to_date});
116   # DateTime->from_kivitendo returns undef if the string cannot be parsed. Therefore test the result.
117   die 'Cannot convert date from string "' . $self->params->{from_date} . '"' if !$from_date;
118   die 'Cannot convert date to string "'   . $self->params->{to_date}   . '"' if !$to_date;
119
120   $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)
121
122   $self->params->{from_date} = $from_date;
123   $self->params->{to_date}   = $to_date;
124
125
126   # check if customernumbers are valid
127   die 'Customer numbers must be given in an array' if 'ARRAY' ne ref $self->params->{customernumbers};
128
129   my $customers = [];
130   if (scalar @{ $self->params->{customernumbers} }) {
131     $customers = SL::DB::Manager::Customer->get_all(where => [ customernumber => $self->params->{customernumbers},
132                                                                or             => [obsolete => undef, obsolete => 0] ]);
133   }
134   die 'Not all customer numbers are valid' if scalar @$customers != scalar @{ $self->params->{customernumbers} };
135
136   # return customer ids
137   $self->params->{customer_ids} = [ map { $_->id } @$customers ];
138
139
140   # check part
141   if ($self->params->{override_part_id} && !SL::DB::Manager::Part->find_by(id => $self->params->{override_part_id},
142                                                                            or => [obsolete => undef, obsolete => 0])) {
143     die 'No valid part found by given override part id';
144   }
145   if ($self->params->{default_part_id} && !SL::DB::Manager::Part->find_by(id => $self->params->{default_part_id},
146                                                                            or => [obsolete => undef, obsolete => 0])) {
147     die 'No valid part found by given default part id';
148   }
149
150
151   # check project
152   if ($self->params->{override_project_id} && !SL::DB::Manager::Project->find_by(id => $self->params->{override_project_id},
153                                                                                  active => 1, valid => 1)) {
154     die 'No valid project found by given override project id';
155   }
156   if ($self->params->{default_project_id} && !SL::DB::Manager::Project->find_by(id => $self->params->{default_project_id},
157                                                                                 active => 1, valid => 1)) {
158     die 'No valid project found by given default project id';
159   }
160
161   return $self->params;
162 }
163
164 sub convert_without_linking {
165   my ($self, $time_recordings) = @_;
166
167   my %time_recordings_by_customer_id;
168   push @{ $time_recordings_by_customer_id{$_->customer_id} }, $_ for @$time_recordings;
169
170   my %convert_params = (
171     rounding         => $self->params->{rounding},
172     override_part_id => $self->params->{override_part_id},
173     default_part_id  => $self->params->{default_part_id},
174   );
175
176   my @donumbers;
177   foreach my $customer_id (keys %time_recordings_by_customer_id) {
178     my $do;
179     if (!eval {
180       $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_customer_id{$customer_id}, %convert_params);
181       1;
182     }) {
183       $self->log_error("creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
184     }
185
186     if ($do) {
187       if (!SL::DB->client->with_transaction(sub {
188         $do->save;
189         $_->update_attributes(booked => 1) for @{$time_recordings_by_customer_id{$customer_id}};
190         1;
191       })) {
192         $self->log_error('saving delivery order failed for time recording ids ' . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
193       } else {
194         push @donumbers, $do->donumber;
195       }
196     }
197   }
198
199   return @donumbers;
200 }
201
202 sub convert_with_linking {
203   my ($self, $time_recordings_by_order_id, $orders_by_order_id) = @_;
204
205   my %convert_params = (
206     rounding        => $self->params->{rounding},
207     override_part_id => $self->params->{override_part_id},
208     default_part_id  => $self->params->{default_part_id},
209   );
210
211   my @donumbers;
212   foreach my $related_order_id (keys %$time_recordings_by_order_id) {
213     my $related_order = $orders_by_order_id->{$related_order_id};
214     my $do;
215     if (!eval {
216       $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_order_id->{$related_order_id}, related_order => $related_order, %convert_params);
217       1;
218     }) {
219       $self->log_error("creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
220     }
221
222     if ($do) {
223       if (!SL::DB->client->with_transaction(sub {
224         $do->save;
225         $_->update_attributes(booked => 1) for @{$time_recordings_by_order_id->{$related_order_id}};
226
227         $related_order->link_to_record($do);
228
229         # TODO extend link_to_record for items, otherwise long-term no d.r.y.
230         foreach my $item (@{ $do->items }) {
231           foreach (qw(orderitems)) {
232             if ($item->{"converted_from_${_}_id"}) {
233               die unless $item->{id};
234               RecordLinks->create_links('mode'       => 'ids',
235                                         'from_table' => $_,
236                                         'from_ids'   => $item->{"converted_from_${_}_id"},
237                                         'to_table'   => 'delivery_order_items',
238                                         'to_id'      => $item->{id},
239               ) || die;
240               delete $item->{"converted_from_${_}_id"};
241             }
242           }
243         }
244
245         # update delivered and item's ship for related order
246         my $helper = SL::Helper::ShippedQty->new->calculate($related_order)->write_to_objects;
247         $related_order->delivered($related_order->{delivered});
248         $_->ship($_->{shipped_qty}) for @{$related_order->items};
249         $related_order->save(cascade => 1);
250
251         1;
252       })) {
253         $self->log_error('saving delivery order failed for time recording ids ' . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
254
255       } else {
256         push @donumbers, $do->donumber;
257       }
258     }
259   }
260
261   return @donumbers;
262 }
263
264 sub get_order_for_time_recording {
265   my ($self, $tr) = @_;
266
267   my $orders;
268
269   if (!$tr->order_id) {
270     # check project
271     my $project_id;
272     $project_id   = $self->params->{override_project_id};
273     $project_id ||= $tr->project_id;
274     $project_id ||= $self->params->{default_project_id};
275
276     if (!$project_id) {
277       $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no project id');
278       return;
279     }
280
281     my $project = SL::DB::Project->load_cached($project_id);
282
283     if (!$project) {
284       $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project not found');
285       return;
286     }
287     if (!$project->active || !$project->valid) {
288       $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project not active or not valid');
289       return;
290     }
291     if ($project->customer_id && $project->customer_id != $tr->customer_id) {
292       $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project customer does not match customer of time recording');
293       return;
294     }
295
296     $orders = SL::DB::Manager::Order->get_all(where        => [customer_id      => $tr->customer_id,
297                                                                or               => [quotation => undef, quotation => 0],
298                                                                globalproject_id => $project_id, ],
299                                               with_objects => ['orderitems']);
300
301   } else {
302     # order_id given
303     my $order = SL::DB::Manager::Order->find_by(id => $tr->order_id);
304     push @$orders, $order if $order;
305   }
306
307   if (!scalar @$orders) {
308     $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no order found');
309     return;
310   }
311
312   # check part
313   my $part_id;
314   $part_id   = $self->params->{override_part_id};
315   $part_id ||= $tr->part_id;
316   $part_id ||= $self->params->{default_part_id};
317
318   if (!$part_id) {
319     $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no part id');
320     return;
321   }
322   my $part = SL::DB::Part->load_cached($part_id);
323   if (!$part->unit_obj->is_time_based) {
324     $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : part unit is not time based');
325     return;
326   }
327
328   my @matching_orders;
329   foreach my $order (@$orders) {
330     if (any { $_->parts_id == $part_id } @{ $order->items_sorted }) {
331       push @matching_orders, $order;
332     }
333   }
334
335   if (1 != scalar @matching_orders) {
336     $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no or more than one orders do match');
337     return;
338   }
339
340   my $matching_order = $matching_orders[0];
341
342   if (!$matching_order->is_sales) {
343     $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : found order is not a sales order');
344     return;
345   }
346
347   if ($matching_order->customer_id != $tr->customer_id) {
348     $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : customer of order does not match customer of time recording');
349     return;
350   }
351
352   if ($tr->project_id && !$self->params->{override_project_id} && $tr->project_id != ($matching_order->globalproject_id || 0)) {
353     $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project of order does not match project of time recording');
354     return;
355   }
356
357   return $matching_order;
358 }
359
360 sub log_error {
361   my ($self, $msg) = @_;
362
363   my $dbg = 0;
364
365   push @{ $self->{job_errors} }, $msg;
366   $::lxdebug->message(LXDebug->WARN(), 'ConvertTimeRecordings: ' . $msg) if $dbg;
367 }
368
369 1;
370
371 __END__
372
373 =pod
374
375 =encoding utf8
376
377 =head1 NAME
378
379 SL::BackgroundJob::ConvertTimeRecordings - Convert time recording
380 entries into delivery orders
381
382 =head1 SYNOPSIS
383
384 Get all time recording entries for the given period and customer numbers
385 and create delivery ordes out of that (using
386 C<SL::DB::DeliveryOrder-E<gt>new_from_time_recordings>).
387
388 =head1 CONFIGURATION
389
390 Some data can be provided to configure this backgroung job.
391 If there is user data and it cannot be validated the background job
392 fails.
393
394 Example:
395
396   from_date: 01.12.2020
397   to_date: 15.12.2020
398   customernumbers: [1,2,3]
399
400 =over 4
401
402 =item C<from_date>
403
404 The date from which on time recordings should be collected. It defaults
405 to the first day of the previous month.
406
407 Example (format depends on your settings):
408
409 from_date: 01.12.2020
410
411 =item C<to_date>
412
413 The date till which time recordings should be collected. It defaults
414 to the last day of the previous month.
415
416 Example (format depends on your settings):
417
418 to_date: 15.12.2020
419
420 =item C<customernumbers>
421
422 An array with the customer numbers for which time recordings should
423 be collected. If not given, time recordings for all customers are
424 collected.
425
426 customernumbers: [c1,22332,334343]
427
428 =item C<override_part_id>
429
430 The part id of a time based service which should be used to
431 book the times instead of the parts which are set in the time
432 recordings.
433
434 =item C<default_part_id>
435
436 The part id of a time based service which should be used to
437 book the times if no part is set in the time recording entry.
438
439 =item C<rounding>
440
441 If set the 0 no rounding of the times will be done otherwise
442 the times will be rounded up to the full quarters of an hour,
443 ie. 0.25h 0.5h 0.75h 1.25h ...
444 Defaults to rounding true (1).
445
446 =item C<link_order>
447
448 If set the job links the created delivery order with the order
449 given in the time recording entry. If there is no order given, then
450 it tries to find an order with the current customer and project
451 number. It tries to do as much automatic workflow processing as the
452 UI.
453 Defaults to off. If set to true (1) the job will fail if there
454 is no sales order which qualifies as a predecessor.
455 Conditions for a predeccesor:
456
457  * Order given in time recording entry OR
458  * Global project_id must match time_recording.project_id OR data.project_id
459  * Customer must match customer in time recording entry
460  * The sales order must have at least one or more time related services
461  * The Project needs to be valid and active
462
463 The job doesn't care if the sales order is already delivered or closed.
464 If the sales order is overdelivered some organisational stuff needs to be done.
465 The sales order may also already be closed, ie the amount is fully billed, but
466 the services are not yet fully delivered (simple case: 'Payment in advance').
467
468 Hint: take a look or extend the job CloseProjectsBelongingToClosedSalesOrder for
469 further automatisation of your organisational needs.
470
471 =item C<override_project_id>
472
473 Use this project id instead of the project id in the time recordings to find
474 a related order. This is only used if C<link_order> is true.
475
476 =item C<default_project_id>
477
478 Use this project id if no project id is set in the time recording
479 entry. This is only used if C<link_order> is true.
480
481 =back
482
483 =head1 TODO
484
485 =over 4
486
487 =item * part and project parameters as numbers
488
489 Add parameters to give part and project not with their ids, but with their
490 numbers. E.g. (default_/override_)part_number,
491 (default_/override_)project_number.
492
493
494 =back
495
496 =head1 AUTHOR
497
498 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>
499
500 =cut