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