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 return t8('No time recordings to convert') if scalar @$time_recordings == 0;
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);
57 push @{ $time_recordings_by_order_id{$order->id} }, $tr;
58 $orders_by_order_id{$order->id} ||= $order;
60 @donumbers = $self->convert_with_linking(\%time_recordings_by_order_id, \%orders_by_order_id);
63 @donumbers = $self->convert_without_linking($time_recordings);
66 my $msg = t8('Number of delivery orders created:');
68 $msg .= scalar @donumbers;
70 $msg .= join ', ', @donumbers;
72 # die if errors exists
73 if (@{ $self->{job_errors} }) {
74 $msg .= ' ' . t8('The following errors occurred:');
76 $msg .= join "\n", @{ $self->{job_errors} };
83 sub initialize_params {
84 my ($self, $data) = @_;
86 # valid parameters with default values
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 => [],
98 # check user input param names
99 foreach my $param (keys %$data) {
100 die "Not a valid parameter: $param" unless exists $valid_params{$param};
104 { map { ($_ => $data->{$_} // $valid_params{$_}) } keys %valid_params }
108 # convert date from string to object
111 $from_date = DateTime->from_kivitendo($self->params->{from_date});
112 $to_date = DateTime->from_kivitendo($self->params->{to_date});
113 # DateTime->from_kivitendo returns undef if the string cannot be parsed. Therefore test the result.
114 die 'Cannot convert date from string "' . $self->params->{from_date} . '"' if !$from_date;
115 die 'Cannot convert date to string "' . $self->params->{to_date} . '"' if !$to_date;
117 $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)
119 $self->params->{from_date} = $from_date;
120 $self->params->{to_date} = $to_date;
123 # check if customernumbers are valid
124 die 'Customer numbers must be given in an array' if 'ARRAY' ne ref $self->params->{customernumbers};
127 if (scalar @{ $self->params->{customernumbers} }) {
128 $customers = SL::DB::Manager::Customer->get_all(where => [ customernumber => $self->params->{customernumbers},
129 or => [obsolete => undef, obsolete => 0] ]);
131 die 'Not all customer numbers are valid' if scalar @$customers != scalar @{ $self->params->{customernumbers} };
133 # return customer ids
134 $self->params->{customer_ids} = [ map { $_->id } @$customers ];
138 if ($self->params->{part_id} && !SL::DB::Manager::Part->find_by(id => $self->params->{part_id},
139 or => [obsolete => undef, obsolete => 0])) {
140 die 'No valid part found by given part id';
145 if ($self->params->{project_id} && !SL::DB::Manager::Project->find_by(id => $self->params->{project_id},
146 active => 1, valid => 1)) {
147 die 'No valid project found by given project id';
150 return $self->params;
153 sub convert_without_linking {
154 my ($self, $time_recordings) = @_;
156 my %time_recordings_by_customer_id;
157 push @{ $time_recordings_by_customer_id{$_->customer_id} }, $_ for @$time_recordings;
159 my %convert_params = map { $_ => $self->params->{$_} } qw(rounding link_order project_id);
160 $convert_params{default_part_id} = $self->params->{part_id};
163 foreach my $customer_id (keys %time_recordings_by_customer_id) {
166 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_customer_id{$customer_id}, %convert_params);
169 $::lxdebug->message(LXDebug->WARN(),
170 "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
171 push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
176 if (!SL::DB->client->with_transaction(sub {
178 $_->update_attributes(booked => 1) for @{$time_recordings_by_customer_id{$customer_id}};
181 $::lxdebug->message(LXDebug->WARN(),
182 "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
183 push @{ $self->{job_errors} }, "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
185 push @donumbers, $do->donumber;
193 sub convert_with_linking {
194 my ($self, $time_recordings_by_order_id, $orders_by_order_id) = @_;
196 my %convert_params = map { $_ => $self->params->{$_} } qw(rounding link_order project_id);
197 $convert_params{default_part_id} = $self->params->{part_id};
200 foreach my $related_order_id (keys %$time_recordings_by_order_id) {
201 my $related_order = $orders_by_order_id->{$related_order_id};
204 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_order_id->{$related_order_id}, related_order => $related_order, %convert_params);
207 $::lxdebug->message(LXDebug->WARN(),
208 "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
209 push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}};
213 if (!SL::DB->client->with_transaction(sub {
215 $_->update_attributes(booked => 1) for @{$time_recordings_by_order_id->{$related_order_id}};
217 $related_order->link_to_record($do);
219 # TODO extend link_to_record for items, otherwise long-term no d.r.y.
220 foreach my $item (@{ $do->items }) {
221 foreach (qw(orderitems)) {
222 if ($item->{"converted_from_${_}_id"}) {
223 die unless $item->{id};
224 RecordLinks->create_links('mode' => 'ids',
226 'from_ids' => $item->{"converted_from_${_}_id"},
227 'to_table' => 'delivery_order_items',
228 'to_id' => $item->{id},
230 delete $item->{"converted_from_${_}_id"};
235 # update delivered and item's ship for related order
236 my $helper = SL::Helper::ShippedQty->new->calculate($related_order)->write_to_objects;
237 $related_order->delivered($related_order->{delivered});
238 $_->ship($_->{shipped_qty}) for @{$related_order->items};
239 $related_order->save(cascade => 1);
243 $::lxdebug->message(LXDebug->WARN(),
244 "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
245 push @{ $self->{job_errors} }, "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}};
247 push @donumbers, $do->donumber;
255 sub get_order_for_time_recording {
256 my ($self, $tr) = @_;
260 if (!$tr->order_id) {
263 #$project_id = $self->overide_project_id;
264 $project_id = $self->params->{project_id};
265 $project_id ||= $tr->project_id;
266 #$project_id ||= $self->default_project_id;
269 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no project id';
270 $::lxdebug->message(LXDebug->WARN(), $err_msg);
271 push @{ $self->{job_errors} }, $err_msg;
275 my $project = SL::DB::Project->load_cached($project_id);
278 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not found';
279 $::lxdebug->message(LXDebug->WARN(), $err_msg);
280 push @{ $self->{job_errors} }, $err_msg;
283 if (!$project->active || !$project->valid) {
284 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not active or not valid';
285 $::lxdebug->message(LXDebug->WARN(), $err_msg);
286 push @{ $self->{job_errors} }, $err_msg;
289 if ($project->customer_id && $project->customer_id != $tr->customer_id) {
290 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project customer does not match customer of time recording';
291 $::lxdebug->message(LXDebug->WARN(), $err_msg);
292 push @{ $self->{job_errors} }, $err_msg;
296 $orders = SL::DB::Manager::Order->get_all(where => [customer_id => $tr->customer_id,
297 or => [quotation => undef, quotation => 0],
298 globalproject_id => $project_id, ],
299 with_objects => ['orderitems']);
303 my $order = SL::DB::Manager::Order->find_by(id => $tr->order_id);
304 push @$orders, $order if $order;
307 if (!scalar @$orders) {
308 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no order found';
309 $::lxdebug->message(LXDebug->WARN(), $err_msg);
310 push @{ $self->{job_errors} }, $err_msg;
316 #$part_id = $self->overide_part_id;
317 $part_id ||= $tr->part_id;
318 #$part_id ||= $self->default_part_id;
319 $part_id ||= $self->params->{part_id};
322 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no part id';
323 $::lxdebug->message(LXDebug->WARN(), $err_msg);
324 push @{ $self->{job_errors} }, $err_msg;
327 my $part = SL::DB::Part->load_cached($part_id);
328 if (!$part->unit_obj->is_time_based) {
329 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : part unit is not time based';
330 $::lxdebug->message(LXDebug->WARN(), $err_msg);
331 push @{ $self->{job_errors} }, $err_msg;
336 foreach my $order (@$orders) {
337 if (any { $_->parts_id == $part_id } @{ $order->items_sorted }) {
338 push @matching_orders, $order;
342 if (1 != scalar @matching_orders) {
343 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no or more than one orders do match';
344 $::lxdebug->message(LXDebug->WARN(), $err_msg);
345 push @{ $self->{job_errors} }, $err_msg;
349 my $matching_order = $matching_orders[0];
351 if (!$matching_order->is_sales) {
352 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : found order is not a sales order';
353 $::lxdebug->message(LXDebug->WARN(), $err_msg);
354 push @{ $self->{job_errors} }, $err_msg;
358 if ($matching_order->customer_id != $tr->customer_id) {
359 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : customer of order does not match customer of time recording';
360 $::lxdebug->message(LXDebug->WARN(), $err_msg);
361 push @{ $self->{job_errors} }, $err_msg;
365 if ($tr->project_id && $tr->project_id != ($matching_order->globalproject_id || 0)) {
366 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project of order does not match project of time recording';
367 $::lxdebug->message(LXDebug->WARN(), $err_msg);
368 push @{ $self->{job_errors} }, $err_msg;
372 return $matching_order;
378 # from_date: 01.12.2020
379 # to_date: 15.12.2020
380 # customernumbers: [1,2,3]
389 SL::BackgroundJob::ConvertTimeRecordings - Convert time recording
390 entries into delivery orders
394 Get all time recording entries for the given period and customer numbers
395 and create delivery ordes out of that (using
396 C<SL::DB::DeliveryOrder-E<gt>new_from_time_recordings>).
400 Some data can be provided to configure this backgroung job.
401 If there is user data and it cannot be validated the background job
402 returns a error messages.
408 The date from which on time recordings should be collected. It defaults
409 to the first day of the previous month.
411 Example (format depends on your settings):
413 from_date: 01.12.2020
417 The date till which time recordings should be collected. It defaults
418 to the last day of the previous month.
420 Example (format depends on your settings):
424 =item C<customernumbers>
426 An array with the customer numbers for which time recordings should
427 be collected. If not given, time recordings for customers are
428 collected. This is the default.
430 customernumbers: [c1,22332,334343]
434 The part id of a time based service which should be used to
435 book the times. If not set the clients config defaults is used.
439 If set the 0 no rounding of the times will be done otherwise
440 the times will be rounded up to th full quarters of an hour,
441 ie. 0.25h 0.5h 0.75h 1.25h ...
442 Defaults to rounding true (1).
446 If set the job links the created delivery order with with the order
447 given in the time recording entry. If there is no order given, then
448 it tries to find an order with with the current customer and project
449 number and tries to do as much automatic workflow processing as the
451 Defaults to off. If set to true (1) the job will fail if there
452 is no sales order which qualifies as a predecessor.
453 Conditions for a predeccesor:
455 * Order given in time recording entry OR
456 * Global project_id must match time_recording.project_id OR data.project_id
457 * Customer must match customer in time recording entry
458 * The sales order must have at least one or more time related services
459 * The Project needs to be valid and active
461 The job doesn't care if the sales order is already delivered or closed.
462 If the sales order is overdelivered some organisational stuff needs to be done.
463 The sales order may also already be closed, ie the amount is fully billed, but
464 the services are not yet fully delivered (simple case: 'Payment in advance').
466 Hint: take a look or extend the job CloseProjectsBelongingToClosedSalesOrder for
467 further automatisation of your organisational needs.
472 Use this project_id instead of the project_id in the time recordings.
478 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>