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