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