Zeiterfassung: Konvertierung: von-bis-Filter benutzt Datum statt End-Zeit.
[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::Locale::String qw(t8);
12
13 use DateTime;
14 use List::Util qw(any);
15 use Try::Tiny;
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_project) ],
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_project => 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_project -> 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   # handle errors with a catch handler
65   try {
66     $from_date   = DateTime->from_kivitendo($self->data->{from_date}) if $self->data->{from_date};
67     $to_date     = DateTime->from_kivitendo($self->data->{to_date})   if $self->data->{to_date};
68   } catch {
69     die "Cannot convert date from string $self->data->{from_date} $self->data->{to_date}\n Details :\n $_"; # not $@
70   };
71   $from_date ||= DateTime->new( day => 1,    month => DateTime->today_local->month, year => DateTime->today_local->year)->subtract(months => 1);
72   $to_date   ||= DateTime->last_day_of_month(month => DateTime->today_local->month, year => DateTime->today_local->year)->subtract(months => 1);
73
74   $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)
75
76   my %customer_where;
77   %customer_where = ('customer.customernumber' => $self->data->{customernumbers}) if 'ARRAY' eq ref $self->data->{customernumbers};
78
79   my $time_recordings = SL::DB::Manager::TimeRecording->get_all(where        => [date => { ge_lt => [ $from_date, $to_date ]},
80                                                                                  or   => [booked => 0, booked => undef],
81                                                                                  %customer_where],
82                                                                 with_objects => ['customer']);
83
84   # no time recordings at all ? -> better exit here before iterating a empty hash
85   # return undef or message unless ref $time_recordings->[0] eq SL::DB::Manager::TimeRecording;
86
87   my @donumbers;
88
89   if ($self->data->{link_project}) {
90     my %time_recordings_by_order_id;
91     my %orders_by_order_id;
92     foreach my $tr (@$time_recordings) {
93       my $order = $self->get_order_for_time_recording($tr);
94       next if !$order;
95       push @{ $time_recordings_by_order_id{$order->id} }, $tr;
96       $orders_by_order_id{$order->id} ||= $order;
97     }
98     @donumbers = $self->convert_with_linking(\%time_recordings_by_order_id, \%orders_by_order_id);
99
100   } else {
101     @donumbers = $self->convert_without_linking($time_recordings);
102   }
103
104   my $msg  = t8('Number of delivery orders created:');
105   $msg    .= ' ';
106   $msg    .= scalar @donumbers;
107   $msg    .= ' (';
108   $msg    .= join ', ', @donumbers;
109   $msg    .= ').';
110   # die if errors exists
111   if (@{ $self->{job_errors} }) {
112     $msg  .= ' ' . t8('The following errors occurred:');
113     $msg  .= ' ';
114     $msg  .= join "\n", @{ $self->{job_errors} };
115     die $msg . "\n";
116   }
117   return $msg;
118 }
119
120 # inits
121
122 sub init_rounding {
123   1
124 }
125
126 sub init_link_project {
127   0
128 }
129
130 # helper
131 sub convert_without_linking {
132   my ($self, $time_recordings) = @_;
133
134   my %time_recordings_by_customer_id;
135   push @{ $time_recordings_by_customer_id{$_->customer_id} }, $_ for @$time_recordings;
136
137   my %convert_params = map { $_ => $self->data->{$_} } qw(rounding link_project project_id);
138   $convert_params{default_part_id} = $self->data->{part_id};
139
140   my @donumbers;
141   foreach my $customer_id (keys %time_recordings_by_customer_id) {
142     my $do;
143     if (!eval {
144       $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_customer_id{$customer_id}, %convert_params);
145       1;
146     }) {
147       $::lxdebug->message(LXDebug->WARN(),
148                           "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
149       push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
150
151     }
152
153     if ($do) {
154       if (!SL::DB->client->with_transaction(sub {
155         $do->save;
156         $_->update_attributes(booked => 1) for @{$time_recordings_by_customer_id{$customer_id}};
157         1;
158       })) {
159         $::lxdebug->message(LXDebug->WARN(),
160                             "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
161         push @{ $self->{job_errors} }, "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
162       } else {
163         push @donumbers, $do->donumber;
164       }
165     }
166   }
167
168   return @donumbers;
169 }
170
171 sub convert_with_linking {
172   my ($self, $time_recordings_by_order_id, $orders_by_order_id) = @_;
173
174   my %convert_params = map { $_ => $self->data->{$_} } qw(rounding link_project project_id);
175   $convert_params{default_part_id} = $self->data->{part_id};
176
177   my @donumbers;
178   foreach my $related_order_id (keys %$time_recordings_by_order_id) {
179     my $related_order = $orders_by_order_id->{$related_order_id};
180     my $do;
181     if (!eval {
182       $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_order_id->{$related_order_id}, related_order => $related_order, %convert_params);
183       1;
184     }) {
185       $::lxdebug->message(LXDebug->WARN(),
186                           "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
187       push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}};
188     }
189
190     if ($do) {
191       if (!SL::DB->client->with_transaction(sub {
192         $do->save;
193         $_->update_attributes(booked => 1) for @{$time_recordings_by_order_id->{$related_order_id}};
194
195         $related_order->link_to_record($do);
196
197         # TODO extend link_to_record for items, otherwise long-term no d.r.y.
198         foreach my $item (@{ $do->items }) {
199           foreach (qw(orderitems)) {
200             if ($item->{"converted_from_${_}_id"}) {
201               die unless $item->{id};
202               RecordLinks->create_links('mode'       => 'ids',
203                                         'from_table' => $_,
204                                         'from_ids'   => $item->{"converted_from_${_}_id"},
205                                         'to_table'   => 'delivery_order_items',
206                                         'to_id'      => $item->{id},
207               ) || die;
208               delete $item->{"converted_from_${_}_id"};
209             }
210           }
211         }
212
213         # Todo: reduce qty on related order
214
215         1;
216       })) {
217         $::lxdebug->message(LXDebug->WARN(),
218                             "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
219         push @{ $self->{job_errors} }, "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}};
220       } else {
221         push @donumbers, $do->donumber;
222       }
223     }
224   }
225
226   return @donumbers;
227 }
228
229 sub get_order_for_time_recording {
230   my ($self, $tr) = @_;
231
232   # check project
233   my $project_id;
234   #$project_id   = $self->overide_project_id;
235   $project_id   = $self->data->{project_id};
236   $project_id ||= $tr->project_id;
237   #$project_id ||= $self->default_project_id;
238
239   if (!$project_id) {
240     my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no project id';
241     $::lxdebug->message(LXDebug->WARN(), $err_msg);
242     push @{ $self->{job_errors} }, $err_msg;
243     return;
244   }
245
246   my $project = SL::DB::Project->load_cached($project_id);
247
248   if (!$project) {
249     my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not found';
250     $::lxdebug->message(LXDebug->WARN(), $err_msg);
251     push @{ $self->{job_errors} }, $err_msg;
252     return;
253   }
254   if (!$project->active || !$project->valid) {
255     my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not active or not valid';
256     $::lxdebug->message(LXDebug->WARN(), $err_msg);
257     push @{ $self->{job_errors} }, $err_msg;
258     return;
259   }
260   if ($project->customer_id && $project->customer_id != $tr->customer_id) {
261     my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project customer does not match customer of time recording';
262     $::lxdebug->message(LXDebug->WARN(), $err_msg);
263     push @{ $self->{job_errors} }, $err_msg;
264     return;
265   }
266
267   # check part
268   my $part_id;
269   #$part_id   = $self->overide_part_id;
270   $part_id ||= $tr->part_id;
271   #$part_id ||= $self->default_part_id;
272   $part_id ||= $self->data->{part_id};
273
274   if (!$part_id) {
275     my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no part id';
276     $::lxdebug->message(LXDebug->WARN(), $err_msg);
277     push @{ $self->{job_errors} }, $err_msg;
278     return;
279   }
280   my $part = SL::DB::Part->load_cached($part_id);
281   if (!$part->unit_obj->is_time_based) {
282     my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : part unit is not time based';
283     $::lxdebug->message(LXDebug->WARN(), $err_msg);
284     push @{ $self->{job_errors} }, $err_msg;
285     return;
286   }
287
288   my $orders = SL::DB::Manager::Order->get_all(where => [customer_id      => $tr->customer_id,
289                                                          or               => [quotation => undef, quotation => 0],
290                                                          globalproject_id => $project_id, ]);
291   my @matching_orders;
292   foreach my $order (@$orders) {
293     if (any { $_->parts_id == $part_id } @{ $order->items_sorted }) {
294       push @matching_orders, $order;
295     }
296   }
297
298   if (1 != scalar @matching_orders) {
299     my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no or more than one orders do match';
300     $::lxdebug->message(LXDebug->WARN(), $err_msg);
301     push @{ $self->{job_errors} }, $err_msg;
302     return;
303   }
304
305   return $matching_orders[0];
306 }
307
308 1;
309
310 # possible data
311 # from_date: 01.12.2020
312 # to_date: 15.12.2020
313 # customernumbers: [1,2,3]
314 __END__
315
316 =pod
317
318 =encoding utf8
319
320 =head1 NAME
321
322 SL::BackgroundJob::ConvertTimeRecordings - Convert time recording
323 entries into delivery orders
324
325 =head1 SYNOPSIS
326
327 Get all time recording entries for the given period and customer numbers
328 and create delivery ordes out of that (using
329 C<SL::DB::DeliveryOrder-E<gt>new_from_time_recordings>).
330
331 =head1 CONFIGURATION
332
333 Some data can be provided to configure this backgroung job.
334 If there is user data and it cannot be validated the background job
335 returns a error messages.
336
337 =over 4
338
339 =item C<from_date>
340
341 The date from which on time recordings should be collected. It defaults
342 to the first day of the previous month.
343
344 Example (format depends on your settings):
345
346 from_date: 01.12.2020
347
348 =item C<to_date>
349
350 The date till which time recordings should be collected. It defaults
351 to the last day of the previous month.
352
353 Example (format depends on your settings):
354
355 to_date: 15.12.2020
356
357 =item C<customernumbers>
358
359 An array with the customer numbers for which time recordings should
360 be collected. If not given, time recordings for customers are
361 collected. This is the default.
362
363 Example (format depends on your settings):
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