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