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
112 my ($from_date, $to_date);
114 if ($self->params->{from_date}) {
115 $from_date = DateTime->from_kivitendo($self->params->{from_date});
116 # no undef and no other type.
117 die unless ref $from_date eq 'DateTime';
119 if ($self->params->{to_date}) {
120 $to_date = DateTime->from_kivitendo($self->params->{to_date});
121 # no undef and no other type.
122 die unless ref $to_date eq 'DateTime';
125 die t8("Cannot convert date.") ."\n" .
126 t8("Input from string: #1", $self->params->{from_date}) . "\n" .
127 t8("Input to string: #1", $self->params->{to_date}) . "\n" .
128 t8("Details: #1", $_);
131 $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)
133 $self->params->{from_date} = $from_date;
134 $self->params->{to_date} = $to_date;
137 # check if customernumbers are valid
138 die 'Customer numbers must be given in an array' if 'ARRAY' ne ref $self->params->{customernumbers};
141 if (scalar @{ $self->params->{customernumbers} }) {
142 $customers = SL::DB::Manager::Customer->get_all(where => [ customernumber => $self->params->{customernumbers},
143 or => [obsolete => undef, obsolete => 0] ]);
145 die 'Not all customer numbers are valid' if scalar @$customers != scalar @{ $self->params->{customernumbers} };
147 # return customer ids
148 $self->params->{customer_ids} = [ map { $_->id } @$customers ];
152 if ($self->params->{override_part_id} && !SL::DB::Manager::Part->find_by(id => $self->params->{override_part_id},
153 or => [obsolete => undef, obsolete => 0])) {
154 die 'No valid part found by given override part id';
156 if ($self->params->{default_part_id} && !SL::DB::Manager::Part->find_by(id => $self->params->{default_part_id},
157 or => [obsolete => undef, obsolete => 0])) {
158 die 'No valid part found by given default part id';
163 if ($self->params->{override_project_id} && !SL::DB::Manager::Project->find_by(id => $self->params->{override_project_id},
164 active => 1, valid => 1)) {
165 die 'No valid project found by given override project id';
167 if ($self->params->{default_project_id} && !SL::DB::Manager::Project->find_by(id => $self->params->{default_project_id},
168 active => 1, valid => 1)) {
169 die 'No valid project found by given default project id';
172 return $self->params;
175 sub convert_without_linking {
176 my ($self, $time_recordings) = @_;
178 my %time_recordings_by_customer_id;
179 push @{ $time_recordings_by_customer_id{$_->customer_id} }, $_ for @$time_recordings;
181 my %convert_params = (
182 rounding => $self->params->{rounding},
183 override_part_id => $self->params->{override_part_id},
184 default_part_id => $self->params->{default_part_id},
188 foreach my $customer_id (keys %time_recordings_by_customer_id) {
191 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_customer_id{$customer_id}, %convert_params);
194 $self->log_error("creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
198 if (!SL::DB->client->with_transaction(sub {
200 $_->update_attributes(booked => 1) for @{$time_recordings_by_customer_id{$customer_id}};
203 $self->log_error('saving delivery order failed for time recording ids ' . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
205 push @donumbers, $do->donumber;
213 sub convert_with_linking {
214 my ($self, $time_recordings_by_order_id, $orders_by_order_id) = @_;
216 my %convert_params = (
217 rounding => $self->params->{rounding},
218 override_part_id => $self->params->{override_part_id},
219 default_part_id => $self->params->{default_part_id},
223 foreach my $related_order_id (keys %$time_recordings_by_order_id) {
224 my $related_order = $orders_by_order_id->{$related_order_id};
227 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_order_id->{$related_order_id}, related_order => $related_order, %convert_params);
230 $self->log_error("creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
234 if (!SL::DB->client->with_transaction(sub {
236 $_->update_attributes(booked => 1) for @{$time_recordings_by_order_id->{$related_order_id}};
238 $related_order->link_to_record($do);
240 # TODO extend link_to_record for items, otherwise long-term no d.r.y.
241 foreach my $item (@{ $do->items }) {
242 foreach (qw(orderitems)) {
243 if ($item->{"converted_from_${_}_id"}) {
244 die unless $item->{id};
245 RecordLinks->create_links('mode' => 'ids',
247 'from_ids' => $item->{"converted_from_${_}_id"},
248 'to_table' => 'delivery_order_items',
249 'to_id' => $item->{id},
251 delete $item->{"converted_from_${_}_id"};
256 # update delivered and item's ship for related order
257 my $helper = SL::Helper::ShippedQty->new->calculate($related_order)->write_to_objects;
258 $related_order->delivered($related_order->{delivered});
259 $_->ship($_->{shipped_qty}) for @{$related_order->items};
260 $related_order->save(cascade => 1);
264 $self->log_error('saving delivery order failed for time recording ids ' . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
267 push @donumbers, $do->donumber;
275 sub get_order_for_time_recording {
276 my ($self, $tr) = @_;
280 if (!$tr->order_id) {
283 $project_id = $self->params->{override_project_id};
284 $project_id ||= $tr->project_id;
285 $project_id ||= $self->params->{default_project_id};
288 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no project id');
292 my $project = SL::DB::Project->load_cached($project_id);
295 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project not found');
298 if (!$project->active || !$project->valid) {
299 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project not active or not valid');
302 if ($project->customer_id && $project->customer_id != $tr->customer_id) {
303 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project customer does not match customer of time recording');
307 $orders = SL::DB::Manager::Order->get_all(where => [customer_id => $tr->customer_id,
308 or => [quotation => undef, quotation => 0],
309 globalproject_id => $project_id, ],
310 with_objects => ['orderitems']);
314 my $order = SL::DB::Manager::Order->find_by(id => $tr->order_id);
315 push @$orders, $order if $order;
318 if (!scalar @$orders) {
319 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no order found');
325 $part_id = $self->params->{override_part_id};
326 $part_id ||= $tr->part_id;
327 $part_id ||= $self->params->{default_part_id};
330 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no part id');
333 my $part = SL::DB::Part->load_cached($part_id);
334 if (!$part->unit_obj->is_time_based) {
335 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : part unit is not time based');
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 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : no or more than one orders do match');
351 my $matching_order = $matching_orders[0];
353 if (!$matching_order->is_sales) {
354 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : found order is not a sales order');
358 if ($matching_order->customer_id != $tr->customer_id) {
359 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : customer of order does not match customer of time recording');
363 if ($tr->project_id && !$self->params->{override_project_id} && $tr->project_id != ($matching_order->globalproject_id || 0)) {
364 $self->log_error('searching related order failed for time recording id ' . $tr->id . ' : project of order does not match project of time recording');
368 return $matching_order;
372 my ($self, $msg) = @_;
376 push @{ $self->{job_errors} }, $msg;
377 $::lxdebug->message(LXDebug->WARN(), 'ConvertTimeRecordings: ' . $msg) if $dbg;
390 SL::BackgroundJob::ConvertTimeRecordings - Convert time recording
391 entries into delivery orders
395 Get all time recording entries for the given period and customer numbers
396 and create delivery ordes out of that (using
397 C<SL::DB::DeliveryOrder-E<gt>new_from_time_recordings>).
401 Some data can be provided to configure this backgroung job.
402 If there is user data and it cannot be validated the background job
407 from_date: 01.12.2020
409 customernumbers: [1,2,3]
415 The date from which on time recordings should be collected. It defaults
416 to the first day of the previous month.
418 Example (format depends on your settings):
420 from_date: 01.12.2020
424 The date till which time recordings should be collected. It defaults
425 to the last day of the previous month.
427 Example (format depends on your settings):
431 =item C<customernumbers>
433 An array with the customer numbers for which time recordings should
434 be collected. If not given, time recordings for all customers are
437 customernumbers: [c1,22332,334343]
439 =item C<override_part_id>
441 The part id of a time based service which should be used to
442 book the times instead of the parts which are set in the time
445 =item C<default_part_id>
447 The part id of a time based service which should be used to
448 book the times if no part is set in the time recording entry.
452 If set the 0 no rounding of the times will be done otherwise
453 the times will be rounded up to the full quarters of an hour,
454 ie. 0.25h 0.5h 0.75h 1.25h ...
455 Defaults to rounding true (1).
459 If set the job links the created delivery order with the order
460 given in the time recording entry. If there is no order given, then
461 it tries to find an order with the current customer and project
462 number. It tries to do as much automatic workflow processing as the
464 Defaults to off. If set to true (1) the job will fail if there
465 is no sales order which qualifies as a predecessor.
466 Conditions for a predeccesor:
468 * Order given in time recording entry OR
469 * Global project_id must match time_recording.project_id OR data.project_id
470 * Customer must match customer in time recording entry
471 * The sales order must have at least one or more time related services
472 * The Project needs to be valid and active
474 The job doesn't care if the sales order is already delivered or closed.
475 If the sales order is overdelivered some organisational stuff needs to be done.
476 The sales order may also already be closed, ie the amount is fully billed, but
477 the services are not yet fully delivered (simple case: 'Payment in advance').
479 Hint: take a look or extend the job CloseProjectsBelongingToClosedSalesOrder for
480 further automatisation of your organisational needs.
482 =item C<override_project_id>
484 Use this project id instead of the project id in the time recordings to find
485 a related order. This is only used if C<link_order> is true.
487 =item C<default_project_id>
489 Use this project id if no project id is set in the time recording
490 entry. This is only used if C<link_order> is true.
498 =item * part and project parameters as numbers
500 Add parameters to give part and project not with their ids, but with their
501 numbers. E.g. (default_/override_)part_number,
502 (default_/override_)project_number.
509 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>