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