1 package SL::BackgroundJob::ConvertTimeRecordings;
5 use parent qw(SL::BackgroundJob::Base);
7 use SL::DB::DeliveryOrder;
10 use SL::DB::TimeRecording;
11 use SL::Helper::ShippedQty;
12 use SL::Locale::String qw(t8);
15 use List::Util qw(any);
18 $_[0]->create_standard_job('7 3 1 * *'); # every first day of month at 03:07
20 use Rose::Object::MakeMethods::Generic (
21 'scalar' => [ qw(params) ],
25 # If job does not throw an error,
26 # success in background_job_histories is 'success'.
27 # It is 'failure' otherwise.
29 # Return value goes to result in background_job_histories.
32 my ($self, $db_obj) = @_;
34 $self->initialize_params($db_obj->data_as_hash) if $db_obj;
36 $self->{$_} = [] for qw(job_errors);
39 %customer_where = ('customer_id' => $self->params->{customer_ids}) if scalar @{ $self->params->{customer_ids} };
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],
47 # no time recordings at all ? -> better exit here before iterating a empty hash
48 # return undef or message unless ref $time_recordings->[0] eq SL::DB::Manager::TimeRecording;
52 if ($self->params->{link_order}) {
53 my %time_recordings_by_order_id;
54 my %orders_by_order_id;
55 foreach my $tr (@$time_recordings) {
56 my $order = $self->get_order_for_time_recording($tr);
58 push @{ $time_recordings_by_order_id{$order->id} }, $tr;
59 $orders_by_order_id{$order->id} ||= $order;
61 @donumbers = $self->convert_with_linking(\%time_recordings_by_order_id, \%orders_by_order_id);
64 @donumbers = $self->convert_without_linking($time_recordings);
67 my $msg = t8('Number of delivery orders created:');
69 $msg .= scalar @donumbers;
71 $msg .= join ', ', @donumbers;
73 # die if errors exists
74 if (@{ $self->{job_errors} }) {
75 $msg .= ' ' . t8('The following errors occurred:');
77 $msg .= join "\n", @{ $self->{job_errors} };
84 sub initialize_params {
85 my ($self, $data) = @_;
87 # valid parameters with default values
89 from_date => DateTime->new( day => 1, month => DateTime->today_local->month, year => DateTime->today_local->year)->subtract(months => 1)->to_kivitendo,
90 to_date => DateTime->last_day_of_month(month => DateTime->today_local->month, year => DateTime->today_local->year)->subtract(months => 1)->to_kivitendo,
91 customernumbers => [],
99 # check user input param names
100 foreach my $param (keys %$data) {
101 die "Not a valid parameter: $param" unless exists $valid_params{$param};
105 { map { ($_ => $data->{$_} // $valid_params{$_}) } keys %valid_params }
109 # convert date from string to object
112 $from_date = DateTime->from_kivitendo($self->params->{from_date});
113 $to_date = DateTime->from_kivitendo($self->params->{to_date});
114 # DateTime->from_kivitendo returns undef if the string cannot be parsed. Therefore test the result.
115 die 'Cannot convert date from string "' . $self->params->{from_date} . '"' if !$from_date;
116 die 'Cannot convert date to string "' . $self->params->{to_date} . '"' if !$to_date;
118 $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)
120 $self->params->{from_date} = $from_date;
121 $self->params->{to_date} = $to_date;
124 # check if customernumbers are valid
125 die 'Customer numbers must be given in an array' if 'ARRAY' ne ref $self->params->{customernumbers};
128 if (scalar @{ $self->params->{customernumbers} }) {
129 $customers = SL::DB::Manager::Customer->get_all(where => [ customernumber => $self->params->{customernumbers},
130 or => [obsolete => undef, obsolete => 0] ]);
132 die 'Not all customer numbers are valid' if scalar @$customers != scalar @{ $self->params->{customernumbers} };
134 # return customer ids
135 $self->params->{customer_ids} = [ map { $_->id } @$customers ];
139 if ($self->params->{part_id} && !SL::DB::Manager::Part->find_by(id => $self->params->{part_id},
140 or => [obsolete => undef, obsolete => 0])) {
141 die 'No valid part found by given part id';
146 if ($self->params->{project_id} && !SL::DB::Manager::Project->find_by(id => $self->params->{project_id},
147 active => 1, valid => 1)) {
148 die 'No valid project found by given project id';
151 return $self->params;
154 sub convert_without_linking {
155 my ($self, $time_recordings) = @_;
157 my %time_recordings_by_customer_id;
158 push @{ $time_recordings_by_customer_id{$_->customer_id} }, $_ for @$time_recordings;
160 my %convert_params = map { $_ => $self->params->{$_} } qw(rounding link_order project_id);
161 $convert_params{default_part_id} = $self->params->{part_id};
164 foreach my $customer_id (keys %time_recordings_by_customer_id) {
167 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_customer_id{$customer_id}, %convert_params);
170 $::lxdebug->message(LXDebug->WARN(),
171 "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
172 push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
177 if (!SL::DB->client->with_transaction(sub {
179 $_->update_attributes(booked => 1) for @{$time_recordings_by_customer_id{$customer_id}};
182 $::lxdebug->message(LXDebug->WARN(),
183 "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
184 push @{ $self->{job_errors} }, "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
186 push @donumbers, $do->donumber;
194 sub convert_with_linking {
195 my ($self, $time_recordings_by_order_id, $orders_by_order_id) = @_;
197 my %convert_params = map { $_ => $self->params->{$_} } qw(rounding link_order project_id);
198 $convert_params{default_part_id} = $self->params->{part_id};
201 foreach my $related_order_id (keys %$time_recordings_by_order_id) {
202 my $related_order = $orders_by_order_id->{$related_order_id};
205 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_order_id->{$related_order_id}, related_order => $related_order, %convert_params);
208 $::lxdebug->message(LXDebug->WARN(),
209 "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
210 push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}};
214 if (!SL::DB->client->with_transaction(sub {
216 $_->update_attributes(booked => 1) for @{$time_recordings_by_order_id->{$related_order_id}};
218 $related_order->link_to_record($do);
220 # TODO extend link_to_record for items, otherwise long-term no d.r.y.
221 foreach my $item (@{ $do->items }) {
222 foreach (qw(orderitems)) {
223 if ($item->{"converted_from_${_}_id"}) {
224 die unless $item->{id};
225 RecordLinks->create_links('mode' => 'ids',
227 'from_ids' => $item->{"converted_from_${_}_id"},
228 'to_table' => 'delivery_order_items',
229 'to_id' => $item->{id},
231 delete $item->{"converted_from_${_}_id"};
236 # update delivered and item's ship for related order
237 my $helper = SL::Helper::ShippedQty->new->calculate($related_order)->write_to_objects;
238 $related_order->delivered($related_order->{delivered});
239 $_->ship($_->{shipped_qty}) for @{$related_order->items};
240 $related_order->save(cascade => 1);
244 $::lxdebug->message(LXDebug->WARN(),
245 "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
246 push @{ $self->{job_errors} }, "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}};
248 push @donumbers, $do->donumber;
256 sub get_order_for_time_recording {
257 my ($self, $tr) = @_;
261 if (!$tr->order_id) {
264 #$project_id = $self->overide_project_id;
265 $project_id = $self->params->{project_id};
266 $project_id ||= $tr->project_id;
267 #$project_id ||= $self->default_project_id;
270 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no project id';
271 $::lxdebug->message(LXDebug->WARN(), $err_msg);
272 push @{ $self->{job_errors} }, $err_msg;
276 my $project = SL::DB::Project->load_cached($project_id);
279 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not found';
280 $::lxdebug->message(LXDebug->WARN(), $err_msg);
281 push @{ $self->{job_errors} }, $err_msg;
284 if (!$project->active || !$project->valid) {
285 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not active or not valid';
286 $::lxdebug->message(LXDebug->WARN(), $err_msg);
287 push @{ $self->{job_errors} }, $err_msg;
290 if ($project->customer_id && $project->customer_id != $tr->customer_id) {
291 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project customer does not match customer of time recording';
292 $::lxdebug->message(LXDebug->WARN(), $err_msg);
293 push @{ $self->{job_errors} }, $err_msg;
297 $orders = SL::DB::Manager::Order->get_all(where => [customer_id => $tr->customer_id,
298 or => [quotation => undef, quotation => 0],
299 globalproject_id => $project_id, ],
300 with_objects => ['orderitems']);
304 my $order = SL::DB::Manager::Order->find_by(id => $tr->order_id);
305 push @$orders, $order if $order;
308 if (!scalar @$orders) {
309 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no order found';
310 $::lxdebug->message(LXDebug->WARN(), $err_msg);
311 push @{ $self->{job_errors} }, $err_msg;
317 #$part_id = $self->overide_part_id;
318 $part_id ||= $tr->part_id;
319 #$part_id ||= $self->default_part_id;
320 $part_id ||= $self->params->{part_id};
323 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no part id';
324 $::lxdebug->message(LXDebug->WARN(), $err_msg);
325 push @{ $self->{job_errors} }, $err_msg;
328 my $part = SL::DB::Part->load_cached($part_id);
329 if (!$part->unit_obj->is_time_based) {
330 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : part unit is not time based';
331 $::lxdebug->message(LXDebug->WARN(), $err_msg);
332 push @{ $self->{job_errors} }, $err_msg;
337 foreach my $order (@$orders) {
338 if (any { $_->parts_id == $part_id } @{ $order->items_sorted }) {
339 push @matching_orders, $order;
343 if (1 != scalar @matching_orders) {
344 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no or more than one orders do match';
345 $::lxdebug->message(LXDebug->WARN(), $err_msg);
346 push @{ $self->{job_errors} }, $err_msg;
350 my $matching_order = $matching_orders[0];
352 if (!$matching_order->is_sales) {
353 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : found order is not a sales order';
354 $::lxdebug->message(LXDebug->WARN(), $err_msg);
355 push @{ $self->{job_errors} }, $err_msg;
359 if ($matching_order->customer_id != $tr->customer_id) {
360 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : customer of order does not match customer of time recording';
361 $::lxdebug->message(LXDebug->WARN(), $err_msg);
362 push @{ $self->{job_errors} }, $err_msg;
366 if ($tr->project_id && $tr->project_id != ($matching_order->globalproject_id || 0)) {
367 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project of order does not match project of time recording';
368 $::lxdebug->message(LXDebug->WARN(), $err_msg);
369 push @{ $self->{job_errors} }, $err_msg;
373 return $matching_order;
379 # from_date: 01.12.2020
380 # to_date: 15.12.2020
381 # customernumbers: [1,2,3]
390 SL::BackgroundJob::ConvertTimeRecordings - Convert time recording
391 entries into delivery orders
395 Get all time recording entries for the given period and customer numbers
396 and create delivery ordes out of that (using
397 C<SL::DB::DeliveryOrder-E<gt>new_from_time_recordings>).
401 Some data can be provided to configure this backgroung job.
402 If there is user data and it cannot be validated the background job
403 returns a error messages.
409 The date from which on time recordings should be collected. It defaults
410 to the first day of the previous month.
412 Example (format depends on your settings):
414 from_date: 01.12.2020
418 The date till which time recordings should be collected. It defaults
419 to the last day of the previous month.
421 Example (format depends on your settings):
425 =item C<customernumbers>
427 An array with the customer numbers for which time recordings should
428 be collected. If not given, time recordings for customers are
429 collected. This is the default.
431 customernumbers: [c1,22332,334343]
435 The part id of a time based service which should be used to
436 book the times. If not set the clients config defaults is used.
440 If set the 0 no rounding of the times will be done otherwise
441 the times will be rounded up to th full quarters of an hour,
442 ie. 0.25h 0.5h 0.75h 1.25h ...
443 Defaults to rounding true (1).
447 If set the job links the created delivery order with with the order
448 given in the time recording entry. If there is no order given, then
449 it tries to find an order with with the current customer and project
450 number and tries to do as much automatic workflow processing as the
452 Defaults to off. If set to true (1) the job will fail if there
453 is no sales order which qualifies as a predecessor.
454 Conditions for a predeccesor:
456 * Order given in time recording entry OR
457 * Global project_id must match time_recording.project_id OR data.project_id
458 * Customer must match customer in time recording entry
459 * The sales order must have at least one or more time related services
460 * The Project needs to be valid and active
462 The job doesn't care if the sales order is already delivered or closed.
463 If the sales order is overdelivered some organisational stuff needs to be done.
464 The sales order may also already be closed, ie the amount is fully billed, but
465 the services are not yet fully delivered (simple case: 'Payment in advance').
467 Hint: take a look or extend the job CloseProjectsBelongingToClosedSalesOrder for
468 further automatisation of your organisational needs.
473 Use this project_id instead of the project_id in the time recordings.
479 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>