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