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 => [],
91 override_part_id => undef,
92 default_part_id => undef,
93 override_project_id => undef,
94 default_project_id => undef,
100 # check user input param names
101 foreach my $param (keys %$data) {
102 die "Not a valid parameter: $param" unless exists $valid_params{$param};
107 { map { ($_ => $data->{$_} // $valid_params{$_}) } keys %valid_params }
111 # convert date from string to object
114 $from_date = DateTime->from_kivitendo($self->params->{from_date});
115 $to_date = DateTime->from_kivitendo($self->params->{to_date});
116 # DateTime->from_kivitendo returns undef if the string cannot be parsed. Therefore test the result.
117 die 'Cannot convert date from string "' . $self->params->{from_date} . '"' if !$from_date;
118 die 'Cannot convert date to string "' . $self->params->{to_date} . '"' if !$to_date;
120 $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)
122 $self->params->{from_date} = $from_date;
123 $self->params->{to_date} = $to_date;
126 # check if customernumbers are valid
127 die 'Customer numbers must be given in an array' if 'ARRAY' ne ref $self->params->{customernumbers};
130 if (scalar @{ $self->params->{customernumbers} }) {
131 $customers = SL::DB::Manager::Customer->get_all(where => [ customernumber => $self->params->{customernumbers},
132 or => [obsolete => undef, obsolete => 0] ]);
134 die 'Not all customer numbers are valid' if scalar @$customers != scalar @{ $self->params->{customernumbers} };
136 # return customer ids
137 $self->params->{customer_ids} = [ map { $_->id } @$customers ];
141 if ($self->params->{override_part_id} && !SL::DB::Manager::Part->find_by(id => $self->params->{override_part_id},
142 or => [obsolete => undef, obsolete => 0])) {
143 die 'No valid part found by given override part id';
145 if ($self->params->{default_part_id} && !SL::DB::Manager::Part->find_by(id => $self->params->{default_part_id},
146 or => [obsolete => undef, obsolete => 0])) {
147 die 'No valid part found by given default part id';
152 if ($self->params->{override_project_id} && !SL::DB::Manager::Project->find_by(id => $self->params->{override_project_id},
153 active => 1, valid => 1)) {
154 die 'No valid project found by given override project id';
156 if ($self->params->{default_project_id} && !SL::DB::Manager::Project->find_by(id => $self->params->{default_project_id},
157 active => 1, valid => 1)) {
158 die 'No valid project found by given default project id';
161 return $self->params;
164 sub convert_without_linking {
165 my ($self, $time_recordings) = @_;
167 my %time_recordings_by_customer_id;
168 push @{ $time_recordings_by_customer_id{$_->customer_id} }, $_ for @$time_recordings;
170 my %convert_params = (
171 rounding => $self->params->{rounding},
172 override_part_id => $self->params->{override_part_id},
173 default_part_id => $self->params->{default_part_id},
177 foreach my $customer_id (keys %time_recordings_by_customer_id) {
180 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_customer_id{$customer_id}, %convert_params);
183 $self->log_error("creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
187 if (!SL::DB->client->with_transaction(sub {
189 $_->update_attributes(booked => 1) for @{$time_recordings_by_customer_id{$customer_id}};
192 $self->log_error('saving delivery order failed for time recording ids ' . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
194 push @donumbers, $do->donumber;
202 sub convert_with_linking {
203 my ($self, $time_recordings_by_order_id, $orders_by_order_id) = @_;
205 my %convert_params = (
206 rounding => $self->params->{rounding},
207 override_part_id => $self->params->{override_part_id},
208 default_part_id => $self->params->{default_part_id},
212 foreach my $related_order_id (keys %$time_recordings_by_order_id) {
213 my $related_order = $orders_by_order_id->{$related_order_id};
216 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_order_id->{$related_order_id}, related_order => $related_order, %convert_params);
219 $self->log_error("creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
223 if (!SL::DB->client->with_transaction(sub {
225 $_->update_attributes(booked => 1) for @{$time_recordings_by_order_id->{$related_order_id}};
227 $related_order->link_to_record($do);
229 # TODO extend link_to_record for items, otherwise long-term no d.r.y.
230 foreach my $item (@{ $do->items }) {
231 foreach (qw(orderitems)) {
232 if ($item->{"converted_from_${_}_id"}) {
233 die unless $item->{id};
234 RecordLinks->create_links('mode' => 'ids',
236 'from_ids' => $item->{"converted_from_${_}_id"},
237 'to_table' => 'delivery_order_items',
238 'to_id' => $item->{id},
240 delete $item->{"converted_from_${_}_id"};
245 # update delivered and item's ship for related order
246 my $helper = SL::Helper::ShippedQty->new->calculate($related_order)->write_to_objects;
247 $related_order->delivered($related_order->{delivered});
248 $_->ship($_->{shipped_qty}) for @{$related_order->items};
249 $related_order->save(cascade => 1);
253 $self->log_error('saving delivery order failed for time recording ids ' . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
256 push @donumbers, $do->donumber;
264 sub get_order_for_time_recording {
265 my ($self, $tr) = @_;
269 if (!$tr->order_id) {
272 $project_id = $self->params->{override_project_id};
273 $project_id ||= $tr->project_id;
274 $project_id ||= $self->params->{default_project_id};
277 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no project id');
281 my $project = SL::DB::Project->load_cached($project_id);
284 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project not found');
287 if (!$project->active || !$project->valid) {
288 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project not active or not valid');
291 if ($project->customer_id && $project->customer_id != $tr->customer_id) {
292 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project customer does not match customer of time recording');
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 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no order found');
314 $part_id = $self->params->{override_part_id};
315 $part_id ||= $tr->part_id;
316 $part_id ||= $self->params->{default_part_id};
319 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no part id');
322 my $part = SL::DB::Part->load_cached($part_id);
323 if (!$part->unit_obj->is_time_based) {
324 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : part unit is not time based');
329 foreach my $order (@$orders) {
330 if (any { $_->parts_id == $part_id } @{ $order->items_sorted }) {
331 push @matching_orders, $order;
335 if (1 != scalar @matching_orders) {
336 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no or more than one orders do match');
340 my $matching_order = $matching_orders[0];
342 if (!$matching_order->is_sales) {
343 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : found order is not a sales order');
347 if ($matching_order->customer_id != $tr->customer_id) {
348 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : customer of order does not match customer of time recording');
352 if ($tr->project_id && !$self->params->{override_project_id} && $tr->project_id != ($matching_order->globalproject_id || 0)) {
353 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project of order does not match project of time recording');
357 return $matching_order;
361 my ($self, $msg) = @_;
365 push @{ $self->{job_errors} }, $msg;
366 $::lxdebug->message(LXDebug->WARN(), 'ConvertTimeRecordings: ' . $msg) if $dbg;
379 SL::BackgroundJob::ConvertTimeRecordings - Convert time recording
380 entries into delivery orders
384 Get all time recording entries for the given period and customer numbers
385 and create delivery ordes out of that (using
386 C<SL::DB::DeliveryOrder-E<gt>new_from_time_recordings>).
390 Some data can be provided to configure this backgroung job.
391 If there is user data and it cannot be validated the background job
396 from_date: 01.12.2020
398 customernumbers: [1,2,3]
404 The date from which on time recordings should be collected. It defaults
405 to the first day of the previous month.
407 Example (format depends on your settings):
409 from_date: 01.12.2020
413 The date till which time recordings should be collected. It defaults
414 to the last day of the previous month.
416 Example (format depends on your settings):
420 =item C<customernumbers>
422 An array with the customer numbers for which time recordings should
423 be collected. If not given, time recordings for all customers are
426 customernumbers: [c1,22332,334343]
428 =item C<override_part_id>
430 The part id of a time based service which should be used to
431 book the times instead of the parts which are set in the time
434 =item C<default_part_id>
436 The part id of a time based service which should be used to
437 book the times if no part is set in the time recording entry.
441 If set the 0 no rounding of the times will be done otherwise
442 the times will be rounded up to the full quarters of an hour,
443 ie. 0.25h 0.5h 0.75h 1.25h ...
444 Defaults to rounding true (1).
448 If set the job links the created delivery order with the order
449 given in the time recording entry. If there is no order given, then
450 it tries to find an order with the current customer and project
451 number. It tries to do as much automatic workflow processing as the
453 Defaults to off. If set to true (1) the job will fail if there
454 is no sales order which qualifies as a predecessor.
455 Conditions for a predeccesor:
457 * Order given in time recording entry OR
458 * Global project_id must match time_recording.project_id OR data.project_id
459 * Customer must match customer in time recording entry
460 * The sales order must have at least one or more time related services
461 * The Project needs to be valid and active
463 The job doesn't care if the sales order is already delivered or closed.
464 If the sales order is overdelivered some organisational stuff needs to be done.
465 The sales order may also already be closed, ie the amount is fully billed, but
466 the services are not yet fully delivered (simple case: 'Payment in advance').
468 Hint: take a look or extend the job CloseProjectsBelongingToClosedSalesOrder for
469 further automatisation of your organisational needs.
471 =item C<override_project_id>
473 Use this project id instead of the project id in the time recordings to find
474 a related order. This is only used if C<link_order> is true.
476 =item C<default_project_id>
478 Use this project id if no project id is set in the time recording
479 entry. This is only used if C<link_order> is true.
487 =item * part and project parameters as numbers
489 Add parameters to give part and project not with their ids, but with their
490 numbers. E.g. (default_/override_)part_number,
491 (default_/override_)project_number.
498 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>