f44e71110792b38e3c9ddbb076c585870873f70f
[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                                                                                  '!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_project}) {
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_project {
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_project 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_project 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   # check project
240   my $project_id;
241   #$project_id   = $self->overide_project_id;
242   $project_id   = $self->data->{project_id};
243   $project_id ||= $tr->project_id;
244   #$project_id ||= $self->default_project_id;
245
246   if (!$project_id) {
247     my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no project id';
248     $::lxdebug->message(LXDebug->WARN(), $err_msg);
249     push @{ $self->{job_errors} }, $err_msg;
250     return;
251   }
252
253   my $project = SL::DB::Project->load_cached($project_id);
254
255   if (!$project) {
256     my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not found';
257     $::lxdebug->message(LXDebug->WARN(), $err_msg);
258     push @{ $self->{job_errors} }, $err_msg;
259     return;
260   }
261   if (!$project->active || !$project->valid) {
262     my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not active or not valid';
263     $::lxdebug->message(LXDebug->WARN(), $err_msg);
264     push @{ $self->{job_errors} }, $err_msg;
265     return;
266   }
267   if ($project->customer_id && $project->customer_id != $tr->customer_id) {
268     my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project customer does not match customer of time recording';
269     $::lxdebug->message(LXDebug->WARN(), $err_msg);
270     push @{ $self->{job_errors} }, $err_msg;
271     return;
272   }
273
274   # check part
275   my $part_id;
276   #$part_id   = $self->overide_part_id;
277   $part_id ||= $tr->part_id;
278   #$part_id ||= $self->default_part_id;
279   $part_id ||= $self->data->{part_id};
280
281   if (!$part_id) {
282     my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no part id';
283     $::lxdebug->message(LXDebug->WARN(), $err_msg);
284     push @{ $self->{job_errors} }, $err_msg;
285     return;
286   }
287   my $part = SL::DB::Part->load_cached($part_id);
288   if (!$part->unit_obj->is_time_based) {
289     my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : part unit is not time based';
290     $::lxdebug->message(LXDebug->WARN(), $err_msg);
291     push @{ $self->{job_errors} }, $err_msg;
292     return;
293   }
294
295   my $orders = SL::DB::Manager::Order->get_all(where        => [customer_id      => $tr->customer_id,
296                                                                 or               => [quotation => undef, quotation => 0],
297                                                                 globalproject_id => $project_id, ],
298                                                with_objects => ['orderitems']);
299   my @matching_orders;
300   foreach my $order (@$orders) {
301     if (any { $_->parts_id == $part_id } @{ $order->items_sorted }) {
302       push @matching_orders, $order;
303     }
304   }
305
306   if (1 != scalar @matching_orders) {
307     my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no or more than one orders do match';
308     $::lxdebug->message(LXDebug->WARN(), $err_msg);
309     push @{ $self->{job_errors} }, $err_msg;
310     return;
311   }
312
313   return $matching_orders[0];
314 }
315
316 1;
317
318 # possible data
319 # from_date: 01.12.2020
320 # to_date: 15.12.2020
321 # customernumbers: [1,2,3]
322 __END__
323
324 =pod
325
326 =encoding utf8
327
328 =head1 NAME
329
330 SL::BackgroundJob::ConvertTimeRecordings - Convert time recording
331 entries into delivery orders
332
333 =head1 SYNOPSIS
334
335 Get all time recording entries for the given period and customer numbers
336 and create delivery ordes out of that (using
337 C<SL::DB::DeliveryOrder-E<gt>new_from_time_recordings>).
338
339 =head1 CONFIGURATION
340
341 Some data can be provided to configure this backgroung job.
342 If there is user data and it cannot be validated the background job
343 returns a error messages.
344
345 =over 4
346
347 =item C<from_date>
348
349 The date from which on time recordings should be collected. It defaults
350 to the first day of the previous month.
351
352 Example (format depends on your settings):
353
354 from_date: 01.12.2020
355
356 =item C<to_date>
357
358 The date till which time recordings should be collected. It defaults
359 to the last day of the previous month.
360
361 Example (format depends on your settings):
362
363 to_date: 15.12.2020
364
365 =item C<customernumbers>
366
367 An array with the customer numbers for which time recordings should
368 be collected. If not given, time recordings for customers are
369 collected. This is the default.
370
371 customernumbers: [c1,22332,334343]
372
373 =item C<part_id>
374
375 The part id of a time based service which should be used to
376 book the times. If not set the clients config defaults is used.
377
378 =item C<rounding>
379
380 If set the 0 no rounding of the times will be done otherwise
381 the times will be rounded up to th full quarters of an hour,
382 ie. 0.25h 0.5h 0.75h 1.25h ...
383 Defaults to rounding true (1).
384
385 =item C<link_project>
386
387 If set the job tries to find a previous Order with the current
388 customer and project number and tries to do as much automatic
389 workflow processing as the UI.
390 Defaults to off. If set to true (1) the job will fail if there
391 is no Sales Orders which qualifies as a predecessor.
392 Conditions for a predeccesor:
393
394  * Global project_id must match time_recording.project_id OR data.project_id
395  * Customer name must match time_recording.customer_id OR data.customernumbers
396  * The sales order must have at least one or more time related services
397  * The Project needs to be valid and active
398
399 The job doesn't care if the Sales Order is already delivered or closed.
400 If the sales order is overdelivered some organisational stuff needs to be done.
401 The sales order may also already be closed, ie the amount is fully billed, but
402 the services are not yet fully delivered (simple case: 'Payment in advance').
403
404 Hint: take a look or extend the job CloseProjectsBelongingToClosedSalesOrder for
405 further automatisation of your organisational needs.
406
407
408 =item C<project_id>
409
410 Use this project_id instead of the project_id in the time recordings.
411
412 =back
413
414 =head1 AUTHOR
415
416 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>
417
418 =cut