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 = (
160 rounding => $self->params->{rounding},
161 default_part_id => $self->params->{part_id},
165 foreach my $customer_id (keys %time_recordings_by_customer_id) {
168 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_customer_id{$customer_id}, %convert_params);
171 $::lxdebug->message(LXDebug->WARN(),
172 "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
173 push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
178 if (!SL::DB->client->with_transaction(sub {
180 $_->update_attributes(booked => 1) for @{$time_recordings_by_customer_id{$customer_id}};
183 $::lxdebug->message(LXDebug->WARN(),
184 "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
185 push @{ $self->{job_errors} }, "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
187 push @donumbers, $do->donumber;
195 sub convert_with_linking {
196 my ($self, $time_recordings_by_order_id, $orders_by_order_id) = @_;
198 my %convert_params = (
199 rounding => $self->params->{rounding},
200 default_part_id => $self->params->{part_id},
204 foreach my $related_order_id (keys %$time_recordings_by_order_id) {
205 my $related_order = $orders_by_order_id->{$related_order_id};
208 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_order_id->{$related_order_id}, related_order => $related_order, %convert_params);
211 $::lxdebug->message(LXDebug->WARN(),
212 "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
213 push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}};
217 if (!SL::DB->client->with_transaction(sub {
219 $_->update_attributes(booked => 1) for @{$time_recordings_by_order_id->{$related_order_id}};
221 $related_order->link_to_record($do);
223 # TODO extend link_to_record for items, otherwise long-term no d.r.y.
224 foreach my $item (@{ $do->items }) {
225 foreach (qw(orderitems)) {
226 if ($item->{"converted_from_${_}_id"}) {
227 die unless $item->{id};
228 RecordLinks->create_links('mode' => 'ids',
230 'from_ids' => $item->{"converted_from_${_}_id"},
231 'to_table' => 'delivery_order_items',
232 'to_id' => $item->{id},
234 delete $item->{"converted_from_${_}_id"};
239 # update delivered and item's ship for related order
240 my $helper = SL::Helper::ShippedQty->new->calculate($related_order)->write_to_objects;
241 $related_order->delivered($related_order->{delivered});
242 $_->ship($_->{shipped_qty}) for @{$related_order->items};
243 $related_order->save(cascade => 1);
247 $::lxdebug->message(LXDebug->WARN(),
248 "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
249 push @{ $self->{job_errors} }, "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}};
251 push @donumbers, $do->donumber;
259 sub get_order_for_time_recording {
260 my ($self, $tr) = @_;
264 if (!$tr->order_id) {
267 #$project_id = $self->overide_project_id;
268 $project_id = $self->params->{project_id};
269 $project_id ||= $tr->project_id;
270 #$project_id ||= $self->default_project_id;
273 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no project id';
274 $::lxdebug->message(LXDebug->WARN(), $err_msg);
275 push @{ $self->{job_errors} }, $err_msg;
279 my $project = SL::DB::Project->load_cached($project_id);
282 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not found';
283 $::lxdebug->message(LXDebug->WARN(), $err_msg);
284 push @{ $self->{job_errors} }, $err_msg;
287 if (!$project->active || !$project->valid) {
288 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not active or not valid';
289 $::lxdebug->message(LXDebug->WARN(), $err_msg);
290 push @{ $self->{job_errors} }, $err_msg;
293 if ($project->customer_id && $project->customer_id != $tr->customer_id) {
294 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project customer does not match customer of time recording';
295 $::lxdebug->message(LXDebug->WARN(), $err_msg);
296 push @{ $self->{job_errors} }, $err_msg;
300 $orders = SL::DB::Manager::Order->get_all(where => [customer_id => $tr->customer_id,
301 or => [quotation => undef, quotation => 0],
302 globalproject_id => $project_id, ],
303 with_objects => ['orderitems']);
307 my $order = SL::DB::Manager::Order->find_by(id => $tr->order_id);
308 push @$orders, $order if $order;
311 if (!scalar @$orders) {
312 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no order found';
313 $::lxdebug->message(LXDebug->WARN(), $err_msg);
314 push @{ $self->{job_errors} }, $err_msg;
320 #$part_id = $self->overide_part_id;
321 $part_id ||= $tr->part_id;
322 #$part_id ||= $self->default_part_id;
323 $part_id ||= $self->params->{part_id};
326 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no part id';
327 $::lxdebug->message(LXDebug->WARN(), $err_msg);
328 push @{ $self->{job_errors} }, $err_msg;
331 my $part = SL::DB::Part->load_cached($part_id);
332 if (!$part->unit_obj->is_time_based) {
333 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : part unit is not time based';
334 $::lxdebug->message(LXDebug->WARN(), $err_msg);
335 push @{ $self->{job_errors} }, $err_msg;
340 foreach my $order (@$orders) {
341 if (any { $_->parts_id == $part_id } @{ $order->items_sorted }) {
342 push @matching_orders, $order;
346 if (1 != scalar @matching_orders) {
347 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no or more than one orders do match';
348 $::lxdebug->message(LXDebug->WARN(), $err_msg);
349 push @{ $self->{job_errors} }, $err_msg;
353 my $matching_order = $matching_orders[0];
355 if (!$matching_order->is_sales) {
356 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : found order is not a sales order';
357 $::lxdebug->message(LXDebug->WARN(), $err_msg);
358 push @{ $self->{job_errors} }, $err_msg;
362 if ($matching_order->customer_id != $tr->customer_id) {
363 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : customer of order does not match customer of time recording';
364 $::lxdebug->message(LXDebug->WARN(), $err_msg);
365 push @{ $self->{job_errors} }, $err_msg;
369 if ($tr->project_id && $tr->project_id != ($matching_order->globalproject_id || 0)) {
370 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project of order does not match project of time recording';
371 $::lxdebug->message(LXDebug->WARN(), $err_msg);
372 push @{ $self->{job_errors} }, $err_msg;
376 return $matching_order;
382 # from_date: 01.12.2020
383 # to_date: 15.12.2020
384 # customernumbers: [1,2,3]
393 SL::BackgroundJob::ConvertTimeRecordings - Convert time recording
394 entries into delivery orders
398 Get all time recording entries for the given period and customer numbers
399 and create delivery ordes out of that (using
400 C<SL::DB::DeliveryOrder-E<gt>new_from_time_recordings>).
404 Some data can be provided to configure this backgroung job.
405 If there is user data and it cannot be validated the background job
406 returns a error messages.
412 The date from which on time recordings should be collected. It defaults
413 to the first day of the previous month.
415 Example (format depends on your settings):
417 from_date: 01.12.2020
421 The date till which time recordings should be collected. It defaults
422 to the last day of the previous month.
424 Example (format depends on your settings):
428 =item C<customernumbers>
430 An array with the customer numbers for which time recordings should
431 be collected. If not given, time recordings for customers are
432 collected. This is the default.
434 customernumbers: [c1,22332,334343]
438 The part id of a time based service which should be used to
439 book the times. If not set the clients config defaults is used.
443 If set the 0 no rounding of the times will be done otherwise
444 the times will be rounded up to th full quarters of an hour,
445 ie. 0.25h 0.5h 0.75h 1.25h ...
446 Defaults to rounding true (1).
450 If set the job links the created delivery order with with the order
451 given in the time recording entry. If there is no order given, then
452 it tries to find an order with with the current customer and project
453 number and tries to do as much automatic workflow processing as the
455 Defaults to off. If set to true (1) the job will fail if there
456 is no sales order which qualifies as a predecessor.
457 Conditions for a predeccesor:
459 * Order given in time recording entry OR
460 * Global project_id must match time_recording.project_id OR data.project_id
461 * Customer must match customer in time recording entry
462 * The sales order must have at least one or more time related services
463 * The Project needs to be valid and active
465 The job doesn't care if the sales order is already delivered or closed.
466 If the sales order is overdelivered some organisational stuff needs to be done.
467 The sales order may also already be closed, ie the amount is fully billed, but
468 the services are not yet fully delivered (simple case: 'Payment in advance').
470 Hint: take a look or extend the job CloseProjectsBelongingToClosedSalesOrder for
471 further automatisation of your organisational needs.
476 Use this project_id instead of the project_id in the time recordings.
482 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>