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