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};
66 $from_date ||= DateTime->new( day => 1, month => DateTime->today_local->month, year => DateTime->today_local->year)->subtract(months => 1);
67 $to_date ||= DateTime->last_day_of_month(month => DateTime->today_local->month, year => DateTime->today_local->year)->subtract(months => 1);
69 $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)
72 %customer_where = ('customer.customernumber' => $self->data->{customernumbers}) if 'ARRAY' eq ref $self->data->{customernumbers};
74 my $time_recordings = SL::DB::Manager::TimeRecording->get_all(where => [date => { ge_lt => [ $from_date, $to_date ]},
75 or => [booked => 0, booked => undef],
79 with_objects => ['customer']);
81 # no time recordings at all ? -> better exit here before iterating a empty hash
82 # return undef or message unless ref $time_recordings->[0] eq SL::DB::Manager::TimeRecording;
86 if ($self->data->{link_order}) {
87 my %time_recordings_by_order_id;
88 my %orders_by_order_id;
89 foreach my $tr (@$time_recordings) {
90 my $order = $self->get_order_for_time_recording($tr);
92 push @{ $time_recordings_by_order_id{$order->id} }, $tr;
93 $orders_by_order_id{$order->id} ||= $order;
95 @donumbers = $self->convert_with_linking(\%time_recordings_by_order_id, \%orders_by_order_id);
98 @donumbers = $self->convert_without_linking($time_recordings);
101 my $msg = t8('Number of delivery orders created:');
103 $msg .= scalar @donumbers;
105 $msg .= join ', ', @donumbers;
107 # die if errors exists
108 if (@{ $self->{job_errors} }) {
109 $msg .= ' ' . t8('The following errors occurred:');
111 $msg .= join "\n", @{ $self->{job_errors} };
123 sub init_link_order {
128 sub convert_without_linking {
129 my ($self, $time_recordings) = @_;
131 my %time_recordings_by_customer_id;
132 push @{ $time_recordings_by_customer_id{$_->customer_id} }, $_ for @$time_recordings;
134 my %convert_params = map { $_ => $self->data->{$_} } qw(rounding link_order project_id);
135 $convert_params{default_part_id} = $self->data->{part_id};
138 foreach my $customer_id (keys %time_recordings_by_customer_id) {
141 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_customer_id{$customer_id}, %convert_params);
144 $::lxdebug->message(LXDebug->WARN(),
145 "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
146 push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
151 if (!SL::DB->client->with_transaction(sub {
153 $_->update_attributes(booked => 1) for @{$time_recordings_by_customer_id{$customer_id}};
156 $::lxdebug->message(LXDebug->WARN(),
157 "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
158 push @{ $self->{job_errors} }, "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
160 push @donumbers, $do->donumber;
168 sub convert_with_linking {
169 my ($self, $time_recordings_by_order_id, $orders_by_order_id) = @_;
171 my %convert_params = map { $_ => $self->data->{$_} } qw(rounding link_order project_id);
172 $convert_params{default_part_id} = $self->data->{part_id};
175 foreach my $related_order_id (keys %$time_recordings_by_order_id) {
176 my $related_order = $orders_by_order_id->{$related_order_id};
179 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_order_id->{$related_order_id}, related_order => $related_order, %convert_params);
182 $::lxdebug->message(LXDebug->WARN(),
183 "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
184 push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}};
188 if (!SL::DB->client->with_transaction(sub {
190 $_->update_attributes(booked => 1) for @{$time_recordings_by_order_id->{$related_order_id}};
192 $related_order->link_to_record($do);
194 # TODO extend link_to_record for items, otherwise long-term no d.r.y.
195 foreach my $item (@{ $do->items }) {
196 foreach (qw(orderitems)) {
197 if ($item->{"converted_from_${_}_id"}) {
198 die unless $item->{id};
199 RecordLinks->create_links('mode' => 'ids',
201 'from_ids' => $item->{"converted_from_${_}_id"},
202 'to_table' => 'delivery_order_items',
203 'to_id' => $item->{id},
205 delete $item->{"converted_from_${_}_id"};
210 # update delivered and item's ship for related order
211 my $helper = SL::Helper::ShippedQty->new->calculate($related_order)->write_to_objects;
212 $related_order->delivered($related_order->{delivered});
213 $_->ship($_->{shipped_qty}) for @{$related_order->items};
214 $related_order->save(cascade => 1);
218 $::lxdebug->message(LXDebug->WARN(),
219 "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
220 push @{ $self->{job_errors} }, "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}};
222 push @donumbers, $do->donumber;
230 sub get_order_for_time_recording {
231 my ($self, $tr) = @_;
235 if (!$tr->order_id) {
238 #$project_id = $self->overide_project_id;
239 $project_id = $self->data->{project_id};
240 $project_id ||= $tr->project_id;
241 #$project_id ||= $self->default_project_id;
244 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no project id';
245 $::lxdebug->message(LXDebug->WARN(), $err_msg);
246 push @{ $self->{job_errors} }, $err_msg;
250 my $project = SL::DB::Project->load_cached($project_id);
253 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not found';
254 $::lxdebug->message(LXDebug->WARN(), $err_msg);
255 push @{ $self->{job_errors} }, $err_msg;
258 if (!$project->active || !$project->valid) {
259 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not active or not valid';
260 $::lxdebug->message(LXDebug->WARN(), $err_msg);
261 push @{ $self->{job_errors} }, $err_msg;
264 if ($project->customer_id && $project->customer_id != $tr->customer_id) {
265 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project customer does not match customer of time recording';
266 $::lxdebug->message(LXDebug->WARN(), $err_msg);
267 push @{ $self->{job_errors} }, $err_msg;
271 $orders = SL::DB::Manager::Order->get_all(where => [customer_id => $tr->customer_id,
272 or => [quotation => undef, quotation => 0],
273 globalproject_id => $project_id, ],
274 with_objects => ['orderitems']);
278 my $order = SL::DB::Manager::Order->find_by(id => $tr->order_id);
279 push @$orders, $order if $order;
282 if (!scalar @$orders) {
283 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no order found';
284 $::lxdebug->message(LXDebug->WARN(), $err_msg);
285 push @{ $self->{job_errors} }, $err_msg;
291 #$part_id = $self->overide_part_id;
292 $part_id ||= $tr->part_id;
293 #$part_id ||= $self->default_part_id;
294 $part_id ||= $self->data->{part_id};
297 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no part id';
298 $::lxdebug->message(LXDebug->WARN(), $err_msg);
299 push @{ $self->{job_errors} }, $err_msg;
302 my $part = SL::DB::Part->load_cached($part_id);
303 if (!$part->unit_obj->is_time_based) {
304 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : part unit is not time based';
305 $::lxdebug->message(LXDebug->WARN(), $err_msg);
306 push @{ $self->{job_errors} }, $err_msg;
311 foreach my $order (@$orders) {
312 if (any { $_->parts_id == $part_id } @{ $order->items_sorted }) {
313 push @matching_orders, $order;
317 if (1 != scalar @matching_orders) {
318 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no or more than one orders do match';
319 $::lxdebug->message(LXDebug->WARN(), $err_msg);
320 push @{ $self->{job_errors} }, $err_msg;
324 my $matching_order = $matching_orders[0];
326 if (!$matching_order->is_sales) {
327 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : found order is not a sales order';
328 $::lxdebug->message(LXDebug->WARN(), $err_msg);
329 push @{ $self->{job_errors} }, $err_msg;
333 if ($matching_order->customer_id != $tr->customer_id) {
334 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : customer of order does not match customer of time recording';
335 $::lxdebug->message(LXDebug->WARN(), $err_msg);
336 push @{ $self->{job_errors} }, $err_msg;
340 if ($tr->project_id && $tr->project_id != ($matching_order->globalproject_id || 0)) {
341 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project of order does not match project of time recording';
342 $::lxdebug->message(LXDebug->WARN(), $err_msg);
343 push @{ $self->{job_errors} }, $err_msg;
347 return $matching_order;
353 # from_date: 01.12.2020
354 # to_date: 15.12.2020
355 # customernumbers: [1,2,3]
364 SL::BackgroundJob::ConvertTimeRecordings - Convert time recording
365 entries into delivery orders
369 Get all time recording entries for the given period and customer numbers
370 and create delivery ordes out of that (using
371 C<SL::DB::DeliveryOrder-E<gt>new_from_time_recordings>).
375 Some data can be provided to configure this backgroung job.
376 If there is user data and it cannot be validated the background job
377 returns a error messages.
383 The date from which on time recordings should be collected. It defaults
384 to the first day of the previous month.
386 Example (format depends on your settings):
388 from_date: 01.12.2020
392 The date till which time recordings should be collected. It defaults
393 to the last day of the previous month.
395 Example (format depends on your settings):
399 =item C<customernumbers>
401 An array with the customer numbers for which time recordings should
402 be collected. If not given, time recordings for customers are
403 collected. This is the default.
405 customernumbers: [c1,22332,334343]
409 The part id of a time based service which should be used to
410 book the times. If not set the clients config defaults is used.
414 If set the 0 no rounding of the times will be done otherwise
415 the times will be rounded up to th full quarters of an hour,
416 ie. 0.25h 0.5h 0.75h 1.25h ...
417 Defaults to rounding true (1).
421 If set the job links the created delivery order with with the order
422 given in the time recording entry. If there is no order given, then
423 it tries to find an order with with the current customer and project
424 number and tries to do as much automatic workflow processing as the
426 Defaults to off. If set to true (1) the job will fail if there
427 is no sales order which qualifies as a predecessor.
428 Conditions for a predeccesor:
430 * Order given in time recording entry OR
431 * Global project_id must match time_recording.project_id OR data.project_id
432 * Customer must match customer in time recording entry
433 * The sales order must have at least one or more time related services
434 * The Project needs to be valid and active
436 The job doesn't care if the sales order is already delivered or closed.
437 If the sales order is overdelivered some organisational stuff needs to be done.
438 The sales order may also already be closed, ie the amount is fully billed, but
439 the services are not yet fully delivered (simple case: 'Payment in advance').
441 Hint: take a look or extend the job CloseProjectsBelongingToClosedSalesOrder for
442 further automatisation of your organisational needs.
447 Use this project_id instead of the project_id in the time recordings.
453 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>