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};
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 = (
161 rounding => $self->params->{rounding},
162 default_part_id => $self->params->{part_id},
166 foreach my $customer_id (keys %time_recordings_by_customer_id) {
169 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_customer_id{$customer_id}, %convert_params);
172 $self->log_error("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 $self->log_error('saving delivery order failed for time recording ids ' . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
183 push @donumbers, $do->donumber;
191 sub convert_with_linking {
192 my ($self, $time_recordings_by_order_id, $orders_by_order_id) = @_;
194 my %convert_params = (
195 rounding => $self->params->{rounding},
196 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 $self->log_error("creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
211 if (!SL::DB->client->with_transaction(sub {
213 $_->update_attributes(booked => 1) for @{$time_recordings_by_order_id->{$related_order_id}};
215 $related_order->link_to_record($do);
217 # TODO extend link_to_record for items, otherwise long-term no d.r.y.
218 foreach my $item (@{ $do->items }) {
219 foreach (qw(orderitems)) {
220 if ($item->{"converted_from_${_}_id"}) {
221 die unless $item->{id};
222 RecordLinks->create_links('mode' => 'ids',
224 'from_ids' => $item->{"converted_from_${_}_id"},
225 'to_table' => 'delivery_order_items',
226 'to_id' => $item->{id},
228 delete $item->{"converted_from_${_}_id"};
233 # update delivered and item's ship for related order
234 my $helper = SL::Helper::ShippedQty->new->calculate($related_order)->write_to_objects;
235 $related_order->delivered($related_order->{delivered});
236 $_->ship($_->{shipped_qty}) for @{$related_order->items};
237 $related_order->save(cascade => 1);
241 $self->log_error('saving delivery order failed for time recording ids ' . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
244 push @donumbers, $do->donumber;
252 sub get_order_for_time_recording {
253 my ($self, $tr) = @_;
257 if (!$tr->order_id) {
260 #$project_id = $self->override_project_id;
261 $project_id = $self->params->{project_id};
262 $project_id ||= $tr->project_id;
263 #$project_id ||= $self->default_project_id;
266 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no project id');
270 my $project = SL::DB::Project->load_cached($project_id);
273 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project not found');
276 if (!$project->active || !$project->valid) {
277 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project not active or not valid');
280 if ($project->customer_id && $project->customer_id != $tr->customer_id) {
281 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project customer does not match customer of time recording');
285 $orders = SL::DB::Manager::Order->get_all(where => [customer_id => $tr->customer_id,
286 or => [quotation => undef, quotation => 0],
287 globalproject_id => $project_id, ],
288 with_objects => ['orderitems']);
292 my $order = SL::DB::Manager::Order->find_by(id => $tr->order_id);
293 push @$orders, $order if $order;
296 if (!scalar @$orders) {
297 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no order found');
303 #$part_id = $self->override_part_id;
304 $part_id ||= $tr->part_id;
305 #$part_id ||= $self->default_part_id;
306 $part_id ||= $self->params->{part_id};
309 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no part id');
312 my $part = SL::DB::Part->load_cached($part_id);
313 if (!$part->unit_obj->is_time_based) {
314 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : part unit is not time based');
319 foreach my $order (@$orders) {
320 if (any { $_->parts_id == $part_id } @{ $order->items_sorted }) {
321 push @matching_orders, $order;
325 if (1 != scalar @matching_orders) {
326 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no or more than one orders do match');
330 my $matching_order = $matching_orders[0];
332 if (!$matching_order->is_sales) {
333 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : found order is not a sales order');
337 if ($matching_order->customer_id != $tr->customer_id) {
338 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : customer of order does not match customer of time recording');
342 if ($tr->project_id && $tr->project_id != ($matching_order->globalproject_id || 0)) {
343 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project of order does not match project of time recording');
347 return $matching_order;
351 my ($self, $msg) = @_;
355 push @{ $self->{job_errors} }, $msg;
356 $::lxdebug->message(LXDebug->WARN(), 'ConvertTimeRecordings: ' . $msg) if $dbg;
369 SL::BackgroundJob::ConvertTimeRecordings - Convert time recording
370 entries into delivery orders
374 Get all time recording entries for the given period and customer numbers
375 and create delivery ordes out of that (using
376 C<SL::DB::DeliveryOrder-E<gt>new_from_time_recordings>).
380 Some data can be provided to configure this backgroung job.
381 If there is user data and it cannot be validated the background job
386 from_date: 01.12.2020
388 customernumbers: [1,2,3]
394 The date from which on time recordings should be collected. It defaults
395 to the first day of the previous month.
397 Example (format depends on your settings):
399 from_date: 01.12.2020
403 The date till which time recordings should be collected. It defaults
404 to the last day of the previous month.
406 Example (format depends on your settings):
410 =item C<customernumbers>
412 An array with the customer numbers for which time recordings should
413 be collected. If not given, time recordings for all customers are
416 customernumbers: [c1,22332,334343]
420 The part id of a time based service which should be used to
421 book the times if no part is set in the time recording entry.
425 If set the 0 no rounding of the times will be done otherwise
426 the times will be rounded up to the full quarters of an hour,
427 ie. 0.25h 0.5h 0.75h 1.25h ...
428 Defaults to rounding true (1).
432 If set the job links the created delivery order with the order
433 given in the time recording entry. If there is no order given, then
434 it tries to find an order with the current customer and project
435 number. It tries to do as much automatic workflow processing as the
437 Defaults to off. If set to true (1) the job will fail if there
438 is no sales order which qualifies as a predecessor.
439 Conditions for a predeccesor:
441 * Order given in time recording entry OR
442 * Global project_id must match time_recording.project_id OR data.project_id
443 * Customer must match customer in time recording entry
444 * The sales order must have at least one or more time related services
445 * The Project needs to be valid and active
447 The job doesn't care if the sales order is already delivered or closed.
448 If the sales order is overdelivered some organisational stuff needs to be done.
449 The sales order may also already be closed, ie the amount is fully billed, but
450 the services are not yet fully delivered (simple case: 'Payment in advance').
452 Hint: take a look or extend the job CloseProjectsBelongingToClosedSalesOrder for
453 further automatisation of your organisational needs.
457 Use this project_id instead of the project_id in the time recordings.
465 =item * part and project parameters as numbers
467 Add parameters to give part and project not with their ids, but with their
468 numbers. E.g. (default_/override_)part_number,
469 (default_/override_)project_number.
471 =item * part and project parameters override and default
473 In the moment, the part id given as parameter is used as the default value.
474 This means, it will be used if there is no part in the time recvording entry.
476 The project id given is used as override parameter. It overrides the project
477 given in the time recording entry.
479 To solve this, there should be parameters named override_part_id,
480 default_part_id, override_project_id and default_project_id.
487 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>