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