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