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