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
115 $from_date = DateTime->from_kivitendo($data->{from_date}) if $data->{from_date};
116 $to_date = DateTime->from_kivitendo($data->{to_date}) if $data->{to_date};
117 die unless $from_date && $to_date;
119 die t8("Cannot convert date.") ."\n" .
120 t8("Input from string: #1", $data->{from_date}) . "\n" .
121 t8("Input to string: #1", $data->{to_date}) . "\n" .
122 t8("Details: #1", $_);
124 $from_date = DateTime->from_kivitendo($self->params->{from_date});
125 $to_date = DateTime->from_kivitendo($self->params->{to_date});
126 # DateTime->from_kivitendo returns undef if the string cannot be parsed. Therefore test the result.
128 $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)
130 $self->params->{from_date} = $from_date;
131 $self->params->{to_date} = $to_date;
134 # check if customernumbers are valid
135 die 'Customer numbers must be given in an array' if 'ARRAY' ne ref $self->params->{customernumbers};
138 if (scalar @{ $self->params->{customernumbers} }) {
139 $customers = SL::DB::Manager::Customer->get_all(where => [ customernumber => $self->params->{customernumbers},
140 or => [obsolete => undef, obsolete => 0] ]);
142 die 'Not all customer numbers are valid' if scalar @$customers != scalar @{ $self->params->{customernumbers} };
144 # return customer ids
145 $self->params->{customer_ids} = [ map { $_->id } @$customers ];
149 if ($self->params->{override_part_id} && !SL::DB::Manager::Part->find_by(id => $self->params->{override_part_id},
150 or => [obsolete => undef, obsolete => 0])) {
151 die 'No valid part found by given override part id';
153 if ($self->params->{default_part_id} && !SL::DB::Manager::Part->find_by(id => $self->params->{default_part_id},
154 or => [obsolete => undef, obsolete => 0])) {
155 die 'No valid part found by given default part id';
160 if ($self->params->{override_project_id} && !SL::DB::Manager::Project->find_by(id => $self->params->{override_project_id},
161 active => 1, valid => 1)) {
162 die 'No valid project found by given override project id';
164 if ($self->params->{default_project_id} && !SL::DB::Manager::Project->find_by(id => $self->params->{default_project_id},
165 active => 1, valid => 1)) {
166 die 'No valid project found by given default project id';
169 return $self->params;
172 sub convert_without_linking {
173 my ($self, $time_recordings) = @_;
175 my %time_recordings_by_customer_id;
176 push @{ $time_recordings_by_customer_id{$_->customer_id} }, $_ for @$time_recordings;
178 my %convert_params = (
179 rounding => $self->params->{rounding},
180 override_part_id => $self->params->{override_part_id},
181 default_part_id => $self->params->{default_part_id},
185 foreach my $customer_id (keys %time_recordings_by_customer_id) {
188 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_customer_id{$customer_id}, %convert_params);
191 $self->log_error("creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
195 if (!SL::DB->client->with_transaction(sub {
197 $_->update_attributes(booked => 1) for @{$time_recordings_by_customer_id{$customer_id}};
200 $self->log_error('saving delivery order failed for time recording ids ' . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
202 push @donumbers, $do->donumber;
210 sub convert_with_linking {
211 my ($self, $time_recordings_by_order_id, $orders_by_order_id) = @_;
213 my %convert_params = (
214 rounding => $self->params->{rounding},
215 override_part_id => $self->params->{override_part_id},
216 default_part_id => $self->params->{default_part_id},
220 foreach my $related_order_id (keys %$time_recordings_by_order_id) {
221 my $related_order = $orders_by_order_id->{$related_order_id};
224 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_order_id->{$related_order_id}, related_order => $related_order, %convert_params);
227 $self->log_error("creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
231 if (!SL::DB->client->with_transaction(sub {
233 $_->update_attributes(booked => 1) for @{$time_recordings_by_order_id->{$related_order_id}};
235 $related_order->link_to_record($do);
237 # TODO extend link_to_record for items, otherwise long-term no d.r.y.
238 foreach my $item (@{ $do->items }) {
239 foreach (qw(orderitems)) {
240 if ($item->{"converted_from_${_}_id"}) {
241 die unless $item->{id};
242 RecordLinks->create_links('mode' => 'ids',
244 'from_ids' => $item->{"converted_from_${_}_id"},
245 'to_table' => 'delivery_order_items',
246 'to_id' => $item->{id},
248 delete $item->{"converted_from_${_}_id"};
253 # update delivered and item's ship for related order
254 my $helper = SL::Helper::ShippedQty->new->calculate($related_order)->write_to_objects;
255 $related_order->delivered($related_order->{delivered});
256 $_->ship($_->{shipped_qty}) for @{$related_order->items};
257 $related_order->save(cascade => 1);
261 $self->log_error('saving delivery order failed for time recording ids ' . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
264 push @donumbers, $do->donumber;
272 sub get_order_for_time_recording {
273 my ($self, $tr) = @_;
277 if (!$tr->order_id) {
280 $project_id = $self->params->{override_project_id};
281 $project_id ||= $tr->project_id;
282 $project_id ||= $self->params->{default_project_id};
285 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no project id');
289 my $project = SL::DB::Project->load_cached($project_id);
292 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project not found');
295 if (!$project->active || !$project->valid) {
296 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project not active or not valid');
299 if ($project->customer_id && $project->customer_id != $tr->customer_id) {
300 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project customer does not match customer of time recording');
304 $orders = SL::DB::Manager::Order->get_all(where => [customer_id => $tr->customer_id,
305 or => [quotation => undef, quotation => 0],
306 globalproject_id => $project_id, ],
307 with_objects => ['orderitems']);
311 my $order = SL::DB::Manager::Order->find_by(id => $tr->order_id);
312 push @$orders, $order if $order;
315 if (!scalar @$orders) {
316 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no order found');
322 $part_id = $self->params->{override_part_id};
323 $part_id ||= $tr->part_id;
324 $part_id ||= $self->params->{default_part_id};
327 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no part id');
330 my $part = SL::DB::Part->load_cached($part_id);
331 if (!$part->unit_obj->is_time_based) {
332 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : part unit is not time based');
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 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no or more than one orders do match');
348 my $matching_order = $matching_orders[0];
350 if (!$matching_order->is_sales) {
351 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : found order is not a sales order');
355 if ($matching_order->customer_id != $tr->customer_id) {
356 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : customer of order does not match customer of time recording');
360 if ($tr->project_id && !$self->params->{override_project_id} && $tr->project_id != ($matching_order->globalproject_id || 0)) {
361 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project of order does not match project of time recording');
365 return $matching_order;
369 my ($self, $msg) = @_;
373 push @{ $self->{job_errors} }, $msg;
374 $::lxdebug->message(LXDebug->WARN(), 'ConvertTimeRecordings: ' . $msg) if $dbg;
387 SL::BackgroundJob::ConvertTimeRecordings - Convert time recording
388 entries into delivery orders
392 Get all time recording entries for the given period and customer numbers
393 and create delivery ordes out of that (using
394 C<SL::DB::DeliveryOrder-E<gt>new_from_time_recordings>).
398 Some data can be provided to configure this backgroung job.
399 If there is user data and it cannot be validated the background job
404 from_date: 01.12.2020
406 customernumbers: [1,2,3]
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 all customers are
434 customernumbers: [c1,22332,334343]
436 =item C<override_part_id>
438 The part id of a time based service which should be used to
439 book the times instead of the parts which are set in the time
442 =item C<default_part_id>
444 The part id of a time based service which should be used to
445 book the times if no part is set in the time recording entry.
449 If set the 0 no rounding of the times will be done otherwise
450 the times will be rounded up to the full quarters of an hour,
451 ie. 0.25h 0.5h 0.75h 1.25h ...
452 Defaults to rounding true (1).
456 If set the job links the created delivery order with the order
457 given in the time recording entry. If there is no order given, then
458 it tries to find an order with the current customer and project
459 number. It tries to do as much automatic workflow processing as the
461 Defaults to off. If set to true (1) the job will fail if there
462 is no sales order which qualifies as a predecessor.
463 Conditions for a predeccesor:
465 * Order given in time recording entry OR
466 * Global project_id must match time_recording.project_id OR data.project_id
467 * Customer must match customer in time recording entry
468 * The sales order must have at least one or more time related services
469 * The Project needs to be valid and active
471 The job doesn't care if the sales order is already delivered or closed.
472 If the sales order is overdelivered some organisational stuff needs to be done.
473 The sales order may also already be closed, ie the amount is fully billed, but
474 the services are not yet fully delivered (simple case: 'Payment in advance').
476 Hint: take a look or extend the job CloseProjectsBelongingToClosedSalesOrder for
477 further automatisation of your organisational needs.
479 =item C<override_project_id>
481 Use this project id instead of the project id in the time recordings to find
482 a related order. This is only used if C<link_order> is true.
484 =item C<default_project_id>
486 Use this project id if no project id is set in the time recording
487 entry. This is only used if C<link_order> is true.
495 =item * part and project parameters as numbers
497 Add parameters to give part and project not with their ids, but with their
498 numbers. E.g. (default_/override_)part_number,
499 (default_/override_)project_number.
506 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>