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(data) ],
22 'scalar --get_set_init' => [ qw(rounding link_order) ],
25 # valid parameters -> better as class members with rose generic set/get
29 customernumbers => '',
37 # If job does not throw an error,
38 # success in background_job_histories is 'success'.
39 # It is 'failure' otherwise.
41 # Return value goes to result in background_job_histories.
44 my ($self, $db_obj) = @_;
46 $self->data($db_obj->data_as_hash) if $db_obj;
48 $self->{$_} = [] for qw(job_errors);
50 # check user input param names
51 foreach my $param (keys %{ $self->data }) {
52 die "Not a valid parameter: $param" unless exists $valid_params{$param};
55 # TODO check user input param values - (defaults are assigned later)
56 # 1- If there are any customer numbers check if they refer to valid customers
57 # otherwise croak and do nothing
58 # 2 .. n Same applies for other params if used at all (rounding -> 0|1 link_order -> 0|1)
60 # from/to date from data. Defaults to begining and end of last month.
61 # TODO get/set see above
64 $from_date = DateTime->from_kivitendo($self->data->{from_date}) if $self->data->{from_date};
65 $to_date = DateTime->from_kivitendo($self->data->{to_date}) if $self->data->{to_date};
67 # DateTime->from_kivitendo returns undef if the string cannot be parsed. Therefore test the result if it shopuld have been parsed.
68 die 'Cannot convert date from string "' . $self->data->{from_date} . '"' if $self->data->{from_date} && !$from_date;
69 die 'Cannot convert date to string "' . $self->data->{to_date} . '"' if $self->data->{to_date} && !$to_date;
71 $from_date ||= DateTime->new( day => 1, month => DateTime->today_local->month, year => DateTime->today_local->year)->subtract(months => 1);
72 $to_date ||= DateTime->last_day_of_month(month => DateTime->today_local->month, year => DateTime->today_local->year)->subtract(months => 1);
74 $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)
77 %customer_where = ('customer.customernumber' => $self->data->{customernumbers}) if 'ARRAY' eq ref $self->data->{customernumbers};
79 my $time_recordings = SL::DB::Manager::TimeRecording->get_all(where => [date => { ge_lt => [ $from_date, $to_date ]},
80 or => [booked => 0, booked => undef],
84 with_objects => ['customer']);
86 # no time recordings at all ? -> better exit here before iterating a empty hash
87 # return undef or message unless ref $time_recordings->[0] eq SL::DB::Manager::TimeRecording;
91 if ($self->data->{link_order}) {
92 my %time_recordings_by_order_id;
93 my %orders_by_order_id;
94 foreach my $tr (@$time_recordings) {
95 my $order = $self->get_order_for_time_recording($tr);
97 push @{ $time_recordings_by_order_id{$order->id} }, $tr;
98 $orders_by_order_id{$order->id} ||= $order;
100 @donumbers = $self->convert_with_linking(\%time_recordings_by_order_id, \%orders_by_order_id);
103 @donumbers = $self->convert_without_linking($time_recordings);
106 my $msg = t8('Number of delivery orders created:');
108 $msg .= scalar @donumbers;
110 $msg .= join ', ', @donumbers;
112 # die if errors exists
113 if (@{ $self->{job_errors} }) {
114 $msg .= ' ' . t8('The following errors occurred:');
116 $msg .= join "\n", @{ $self->{job_errors} };
128 sub init_link_order {
133 sub convert_without_linking {
134 my ($self, $time_recordings) = @_;
136 my %time_recordings_by_customer_id;
137 push @{ $time_recordings_by_customer_id{$_->customer_id} }, $_ for @$time_recordings;
139 my %convert_params = map { $_ => $self->data->{$_} } qw(rounding link_order project_id);
140 $convert_params{default_part_id} = $self->data->{part_id};
143 foreach my $customer_id (keys %time_recordings_by_customer_id) {
146 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_customer_id{$customer_id}, %convert_params);
149 $::lxdebug->message(LXDebug->WARN(),
150 "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
151 push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
156 if (!SL::DB->client->with_transaction(sub {
158 $_->update_attributes(booked => 1) for @{$time_recordings_by_customer_id{$customer_id}};
161 $::lxdebug->message(LXDebug->WARN(),
162 "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
163 push @{ $self->{job_errors} }, "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
165 push @donumbers, $do->donumber;
173 sub convert_with_linking {
174 my ($self, $time_recordings_by_order_id, $orders_by_order_id) = @_;
176 my %convert_params = map { $_ => $self->data->{$_} } qw(rounding link_order project_id);
177 $convert_params{default_part_id} = $self->data->{part_id};
180 foreach my $related_order_id (keys %$time_recordings_by_order_id) {
181 my $related_order = $orders_by_order_id->{$related_order_id};
184 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_order_id->{$related_order_id}, related_order => $related_order, %convert_params);
187 $::lxdebug->message(LXDebug->WARN(),
188 "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
189 push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}};
193 if (!SL::DB->client->with_transaction(sub {
195 $_->update_attributes(booked => 1) for @{$time_recordings_by_order_id->{$related_order_id}};
197 $related_order->link_to_record($do);
199 # TODO extend link_to_record for items, otherwise long-term no d.r.y.
200 foreach my $item (@{ $do->items }) {
201 foreach (qw(orderitems)) {
202 if ($item->{"converted_from_${_}_id"}) {
203 die unless $item->{id};
204 RecordLinks->create_links('mode' => 'ids',
206 'from_ids' => $item->{"converted_from_${_}_id"},
207 'to_table' => 'delivery_order_items',
208 'to_id' => $item->{id},
210 delete $item->{"converted_from_${_}_id"};
215 # update delivered and item's ship for related order
216 my $helper = SL::Helper::ShippedQty->new->calculate($related_order)->write_to_objects;
217 $related_order->delivered($related_order->{delivered});
218 $_->ship($_->{shipped_qty}) for @{$related_order->items};
219 $related_order->save(cascade => 1);
223 $::lxdebug->message(LXDebug->WARN(),
224 "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
225 push @{ $self->{job_errors} }, "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}};
227 push @donumbers, $do->donumber;
235 sub get_order_for_time_recording {
236 my ($self, $tr) = @_;
240 if (!$tr->order_id) {
243 #$project_id = $self->overide_project_id;
244 $project_id = $self->data->{project_id};
245 $project_id ||= $tr->project_id;
246 #$project_id ||= $self->default_project_id;
249 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no project id';
250 $::lxdebug->message(LXDebug->WARN(), $err_msg);
251 push @{ $self->{job_errors} }, $err_msg;
255 my $project = SL::DB::Project->load_cached($project_id);
258 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not found';
259 $::lxdebug->message(LXDebug->WARN(), $err_msg);
260 push @{ $self->{job_errors} }, $err_msg;
263 if (!$project->active || !$project->valid) {
264 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not active or not valid';
265 $::lxdebug->message(LXDebug->WARN(), $err_msg);
266 push @{ $self->{job_errors} }, $err_msg;
269 if ($project->customer_id && $project->customer_id != $tr->customer_id) {
270 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project customer does not match customer of time recording';
271 $::lxdebug->message(LXDebug->WARN(), $err_msg);
272 push @{ $self->{job_errors} }, $err_msg;
276 $orders = SL::DB::Manager::Order->get_all(where => [customer_id => $tr->customer_id,
277 or => [quotation => undef, quotation => 0],
278 globalproject_id => $project_id, ],
279 with_objects => ['orderitems']);
283 my $order = SL::DB::Manager::Order->find_by(id => $tr->order_id);
284 push @$orders, $order if $order;
287 if (!scalar @$orders) {
288 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no order found';
289 $::lxdebug->message(LXDebug->WARN(), $err_msg);
290 push @{ $self->{job_errors} }, $err_msg;
296 #$part_id = $self->overide_part_id;
297 $part_id ||= $tr->part_id;
298 #$part_id ||= $self->default_part_id;
299 $part_id ||= $self->data->{part_id};
302 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no part id';
303 $::lxdebug->message(LXDebug->WARN(), $err_msg);
304 push @{ $self->{job_errors} }, $err_msg;
307 my $part = SL::DB::Part->load_cached($part_id);
308 if (!$part->unit_obj->is_time_based) {
309 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : part unit is not time based';
310 $::lxdebug->message(LXDebug->WARN(), $err_msg);
311 push @{ $self->{job_errors} }, $err_msg;
316 foreach my $order (@$orders) {
317 if (any { $_->parts_id == $part_id } @{ $order->items_sorted }) {
318 push @matching_orders, $order;
322 if (1 != scalar @matching_orders) {
323 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no or more than one orders do match';
324 $::lxdebug->message(LXDebug->WARN(), $err_msg);
325 push @{ $self->{job_errors} }, $err_msg;
329 my $matching_order = $matching_orders[0];
331 if (!$matching_order->is_sales) {
332 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : found order is not a sales order';
333 $::lxdebug->message(LXDebug->WARN(), $err_msg);
334 push @{ $self->{job_errors} }, $err_msg;
338 if ($matching_order->customer_id != $tr->customer_id) {
339 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : customer of order does not match customer of time recording';
340 $::lxdebug->message(LXDebug->WARN(), $err_msg);
341 push @{ $self->{job_errors} }, $err_msg;
345 if ($tr->project_id && $tr->project_id != ($matching_order->globalproject_id || 0)) {
346 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project of order does not match project of time recording';
347 $::lxdebug->message(LXDebug->WARN(), $err_msg);
348 push @{ $self->{job_errors} }, $err_msg;
352 return $matching_order;
358 # from_date: 01.12.2020
359 # to_date: 15.12.2020
360 # customernumbers: [1,2,3]
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
382 returns a error messages.
388 The date from which on time recordings should be collected. It defaults
389 to the first day of the previous month.
391 Example (format depends on your settings):
393 from_date: 01.12.2020
397 The date till which time recordings should be collected. It defaults
398 to the last day of the previous month.
400 Example (format depends on your settings):
404 =item C<customernumbers>
406 An array with the customer numbers for which time recordings should
407 be collected. If not given, time recordings for customers are
408 collected. This is the default.
410 customernumbers: [c1,22332,334343]
414 The part id of a time based service which should be used to
415 book the times. If not set the clients config defaults is used.
419 If set the 0 no rounding of the times will be done otherwise
420 the times will be rounded up to th full quarters of an hour,
421 ie. 0.25h 0.5h 0.75h 1.25h ...
422 Defaults to rounding true (1).
426 If set the job links the created delivery order with with the order
427 given in the time recording entry. If there is no order given, then
428 it tries to find an order with with the current customer and project
429 number and tries to do as much automatic workflow processing as the
431 Defaults to off. If set to true (1) the job will fail if there
432 is no sales order which qualifies as a predecessor.
433 Conditions for a predeccesor:
435 * Order given in time recording entry OR
436 * Global project_id must match time_recording.project_id OR data.project_id
437 * Customer must match customer in time recording entry
438 * The sales order must have at least one or more time related services
439 * The Project needs to be valid and active
441 The job doesn't care if the sales order is already delivered or closed.
442 If the sales order is overdelivered some organisational stuff needs to be done.
443 The sales order may also already be closed, ie the amount is fully billed, but
444 the services are not yet fully delivered (simple case: 'Payment in advance').
446 Hint: take a look or extend the job CloseProjectsBelongingToClosedSalesOrder for
447 further automatisation of your organisational needs.
452 Use this project_id instead of the project_id in the time recordings.
458 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>