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