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