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);
19 $_[0]->create_standard_job('7 3 1 * *'); # every first day of month at 03:07
21 use Rose::Object::MakeMethods::Generic (
22 'scalar' => [ qw(data) ],
23 'scalar --get_set_init' => [ qw(rounding link_order) ],
26 # valid parameters -> better as class members with rose generic set/get
30 customernumbers => '',
38 # If job does not throw an error,
39 # success in background_job_histories is 'success'.
40 # It is 'failure' otherwise.
42 # Return value goes to result in background_job_histories.
45 my ($self, $db_obj) = @_;
47 $self->data($db_obj->data_as_hash) if $db_obj;
49 $self->{$_} = [] for qw(job_errors);
51 # check user input param names
52 foreach my $param (keys %{ $self->data }) {
53 die "Not a valid parameter: $param" unless exists $valid_params{$param};
56 # TODO check user input param values - (defaults are assigned later)
57 # 1- If there are any customer numbers check if they refer to valid customers
58 # otherwise croak and do nothing
59 # 2 .. n Same applies for other params if used at all (rounding -> 0|1 link_order -> 0|1)
61 # from/to date from data. Defaults to begining and end of last month.
62 # TODO get/set see above
65 # handle errors with a catch handler
67 $from_date = DateTime->from_kivitendo($self->data->{from_date}) if $self->data->{from_date};
68 $to_date = DateTime->from_kivitendo($self->data->{to_date}) if $self->data->{to_date};
70 die "Cannot convert date from string $self->data->{from_date} $self->data->{to_date}\n Details :\n $_"; # not $@
72 $from_date ||= DateTime->new( day => 1, month => DateTime->today_local->month, year => DateTime->today_local->year)->subtract(months => 1);
73 $to_date ||= DateTime->last_day_of_month(month => DateTime->today_local->month, year => DateTime->today_local->year)->subtract(months => 1);
75 $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)
78 %customer_where = ('customer.customernumber' => $self->data->{customernumbers}) if 'ARRAY' eq ref $self->data->{customernumbers};
80 my $time_recordings = SL::DB::Manager::TimeRecording->get_all(where => [date => { ge_lt => [ $from_date, $to_date ]},
81 or => [booked => 0, booked => undef],
85 with_objects => ['customer']);
87 # no time recordings at all ? -> better exit here before iterating a empty hash
88 # return undef or message unless ref $time_recordings->[0] eq SL::DB::Manager::TimeRecording;
92 if ($self->data->{link_order}) {
93 my %time_recordings_by_order_id;
94 my %orders_by_order_id;
95 foreach my $tr (@$time_recordings) {
96 my $order = $self->get_order_for_time_recording($tr);
98 push @{ $time_recordings_by_order_id{$order->id} }, $tr;
99 $orders_by_order_id{$order->id} ||= $order;
101 @donumbers = $self->convert_with_linking(\%time_recordings_by_order_id, \%orders_by_order_id);
104 @donumbers = $self->convert_without_linking($time_recordings);
107 my $msg = t8('Number of delivery orders created:');
109 $msg .= scalar @donumbers;
111 $msg .= join ', ', @donumbers;
113 # die if errors exists
114 if (@{ $self->{job_errors} }) {
115 $msg .= ' ' . t8('The following errors occurred:');
117 $msg .= join "\n", @{ $self->{job_errors} };
129 sub init_link_order {
134 sub convert_without_linking {
135 my ($self, $time_recordings) = @_;
137 my %time_recordings_by_customer_id;
138 push @{ $time_recordings_by_customer_id{$_->customer_id} }, $_ for @$time_recordings;
140 my %convert_params = map { $_ => $self->data->{$_} } qw(rounding link_order project_id);
141 $convert_params{default_part_id} = $self->data->{part_id};
144 foreach my $customer_id (keys %time_recordings_by_customer_id) {
147 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_customer_id{$customer_id}, %convert_params);
150 $::lxdebug->message(LXDebug->WARN(),
151 "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
152 push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
157 if (!SL::DB->client->with_transaction(sub {
159 $_->update_attributes(booked => 1) for @{$time_recordings_by_customer_id{$customer_id}};
162 $::lxdebug->message(LXDebug->WARN(),
163 "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
164 push @{ $self->{job_errors} }, "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
166 push @donumbers, $do->donumber;
174 sub convert_with_linking {
175 my ($self, $time_recordings_by_order_id, $orders_by_order_id) = @_;
177 my %convert_params = map { $_ => $self->data->{$_} } qw(rounding link_order project_id);
178 $convert_params{default_part_id} = $self->data->{part_id};
181 foreach my $related_order_id (keys %$time_recordings_by_order_id) {
182 my $related_order = $orders_by_order_id->{$related_order_id};
185 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_order_id->{$related_order_id}, related_order => $related_order, %convert_params);
188 $::lxdebug->message(LXDebug->WARN(),
189 "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
190 push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}};
194 if (!SL::DB->client->with_transaction(sub {
196 $_->update_attributes(booked => 1) for @{$time_recordings_by_order_id->{$related_order_id}};
198 $related_order->link_to_record($do);
200 # TODO extend link_to_record for items, otherwise long-term no d.r.y.
201 foreach my $item (@{ $do->items }) {
202 foreach (qw(orderitems)) {
203 if ($item->{"converted_from_${_}_id"}) {
204 die unless $item->{id};
205 RecordLinks->create_links('mode' => 'ids',
207 'from_ids' => $item->{"converted_from_${_}_id"},
208 'to_table' => 'delivery_order_items',
209 'to_id' => $item->{id},
211 delete $item->{"converted_from_${_}_id"};
216 # update delivered and item's ship for related order
217 my $helper = SL::Helper::ShippedQty->new->calculate($related_order)->write_to_objects;
218 $related_order->delivered($related_order->{delivered});
219 $_->ship($_->{shipped_qty}) for @{$related_order->items};
220 $related_order->save(cascade => 1);
224 $::lxdebug->message(LXDebug->WARN(),
225 "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
226 push @{ $self->{job_errors} }, "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}};
228 push @donumbers, $do->donumber;
236 sub get_order_for_time_recording {
237 my ($self, $tr) = @_;
241 if (!$tr->order_id) {
244 #$project_id = $self->overide_project_id;
245 $project_id = $self->data->{project_id};
246 $project_id ||= $tr->project_id;
247 #$project_id ||= $self->default_project_id;
250 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no project id';
251 $::lxdebug->message(LXDebug->WARN(), $err_msg);
252 push @{ $self->{job_errors} }, $err_msg;
256 my $project = SL::DB::Project->load_cached($project_id);
259 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not found';
260 $::lxdebug->message(LXDebug->WARN(), $err_msg);
261 push @{ $self->{job_errors} }, $err_msg;
264 if (!$project->active || !$project->valid) {
265 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not active or not valid';
266 $::lxdebug->message(LXDebug->WARN(), $err_msg);
267 push @{ $self->{job_errors} }, $err_msg;
270 if ($project->customer_id && $project->customer_id != $tr->customer_id) {
271 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project customer does not match customer of time recording';
272 $::lxdebug->message(LXDebug->WARN(), $err_msg);
273 push @{ $self->{job_errors} }, $err_msg;
277 $orders = SL::DB::Manager::Order->get_all(where => [customer_id => $tr->customer_id,
278 or => [quotation => undef, quotation => 0],
279 globalproject_id => $project_id, ],
280 with_objects => ['orderitems']);
284 my $order = SL::DB::Manager::Order->find_by(id => $tr->order_id);
285 push @$orders, $order if $order;
288 if (!scalar @$orders) {
289 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no order found';
290 $::lxdebug->message(LXDebug->WARN(), $err_msg);
291 push @{ $self->{job_errors} }, $err_msg;
297 #$part_id = $self->overide_part_id;
298 $part_id ||= $tr->part_id;
299 #$part_id ||= $self->default_part_id;
300 $part_id ||= $self->data->{part_id};
303 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no part id';
304 $::lxdebug->message(LXDebug->WARN(), $err_msg);
305 push @{ $self->{job_errors} }, $err_msg;
308 my $part = SL::DB::Part->load_cached($part_id);
309 if (!$part->unit_obj->is_time_based) {
310 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : part unit is not time based';
311 $::lxdebug->message(LXDebug->WARN(), $err_msg);
312 push @{ $self->{job_errors} }, $err_msg;
317 foreach my $order (@$orders) {
318 if (any { $_->parts_id == $part_id } @{ $order->items_sorted }) {
319 push @matching_orders, $order;
323 if (1 != scalar @matching_orders) {
324 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no or more than one orders do match';
325 $::lxdebug->message(LXDebug->WARN(), $err_msg);
326 push @{ $self->{job_errors} }, $err_msg;
330 my $matching_order = $matching_orders[0];
332 if (!$matching_order->is_sales) {
333 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : found order is not a sales order';
334 $::lxdebug->message(LXDebug->WARN(), $err_msg);
335 push @{ $self->{job_errors} }, $err_msg;
339 if ($matching_order->customer_id != $tr->customer_id) {
340 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : customer of order does not match customer of time recording';
341 $::lxdebug->message(LXDebug->WARN(), $err_msg);
342 push @{ $self->{job_errors} }, $err_msg;
346 if ($tr->project_id && $tr->project_id != ($matching_order->globalproject_id || 0)) {
347 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project of order does not match project of time recording';
348 $::lxdebug->message(LXDebug->WARN(), $err_msg);
349 push @{ $self->{job_errors} }, $err_msg;
353 return $matching_order;
359 # from_date: 01.12.2020
360 # to_date: 15.12.2020
361 # customernumbers: [1,2,3]
370 SL::BackgroundJob::ConvertTimeRecordings - Convert time recording
371 entries into delivery orders
375 Get all time recording entries for the given period and customer numbers
376 and create delivery ordes out of that (using
377 C<SL::DB::DeliveryOrder-E<gt>new_from_time_recordings>).
381 Some data can be provided to configure this backgroung job.
382 If there is user data and it cannot be validated the background job
383 returns a error messages.
389 The date from which on time recordings should be collected. It defaults
390 to the first day of the previous month.
392 Example (format depends on your settings):
394 from_date: 01.12.2020
398 The date till which time recordings should be collected. It defaults
399 to the last day of the previous month.
401 Example (format depends on your settings):
405 =item C<customernumbers>
407 An array with the customer numbers for which time recordings should
408 be collected. If not given, time recordings for customers are
409 collected. This is the default.
411 customernumbers: [c1,22332,334343]
415 The part id of a time based service which should be used to
416 book the times. If not set the clients config defaults is used.
420 If set the 0 no rounding of the times will be done otherwise
421 the times will be rounded up to th full quarters of an hour,
422 ie. 0.25h 0.5h 0.75h 1.25h ...
423 Defaults to rounding true (1).
427 If set the job links the created delivery order with with the order
428 given in the time recording entry. If there is no order given, then
429 it tries to find an order with with the current customer and project
430 number and tries to do as much automatic workflow processing as the
432 Defaults to off. If set to true (1) the job will fail if there
433 is no sales order which qualifies as a predecessor.
434 Conditions for a predeccesor:
436 * Order given in time recording entry OR
437 * Global project_id must match time_recording.project_id OR data.project_id
438 * Customer must match customer in time recording entry
439 * The sales order must have at least one or more time related services
440 * The Project needs to be valid and active
442 The job doesn't care if the sales order is already delivered or closed.
443 If the sales order is overdelivered some organisational stuff needs to be done.
444 The sales order may also already be closed, ie the amount is fully billed, but
445 the services are not yet fully delivered (simple case: 'Payment in advance').
447 Hint: take a look or extend the job CloseProjectsBelongingToClosedSalesOrder for
448 further automatisation of your organisational needs.
453 Use this project_id instead of the project_id in the time recordings.
459 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>