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