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::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_project) ],
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_project -> 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 => [end_time => { ge_lt => [ $from_date, $to_date ]},
81 or => [booked => 0, booked => undef],
83 with_objects => ['customer']);
85 # no time recordings at all ? -> better exit here before iterating a empty hash
86 # return undef or message unless ref $time_recordings->[0] eq SL::DB::Manager::TimeRecording;
90 if ($self->data->{link_project}) {
91 my %time_recordings_by_order_id;
92 my %orders_by_order_id;
93 foreach my $tr (@$time_recordings) {
94 my $order = $self->get_order_for_time_recording($tr);
96 push @{ $time_recordings_by_order_id{$order->id} }, $tr;
97 $orders_by_order_id{$order->id} ||= $order;
99 @donumbers = $self->convert_with_linking(\%time_recordings_by_order_id, \%orders_by_order_id);
102 @donumbers = $self->convert_without_linking($time_recordings);
105 my $msg = t8('Number of delivery orders created:');
107 $msg .= scalar @donumbers;
109 $msg .= join ', ', @donumbers;
111 # die if errors exists
112 if (@{ $self->{job_errors} }) {
113 $msg .= ' ' . t8('The following errors occurred:');
115 $msg .= join "\n", @{ $self->{job_errors} };
127 sub init_link_project {
132 sub convert_without_linking {
133 my ($self, $time_recordings) = @_;
135 my %time_recordings_by_customer_id;
136 push @{ $time_recordings_by_customer_id{$_->customer_id} }, $_ for @$time_recordings;
138 my %convert_params = map { $_ => $self->data->{$_} } qw(rounding link_project project_id);
139 $convert_params{default_part_id} = $self->data->{part_id};
142 foreach my $customer_id (keys %time_recordings_by_customer_id) {
145 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_customer_id{$customer_id}, %convert_params);
148 $::lxdebug->message(LXDebug->WARN(),
149 "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
150 push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
155 if (!SL::DB->client->with_transaction(sub {
157 $_->update_attributes(booked => 1) for @{$time_recordings_by_customer_id{$customer_id}};
160 $::lxdebug->message(LXDebug->WARN(),
161 "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
162 push @{ $self->{job_errors} }, "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
164 push @donumbers, $do->donumber;
172 sub convert_with_linking {
173 my ($self, $time_recordings_by_order_id, $orders_by_order_id) = @_;
175 my %convert_params = map { $_ => $self->data->{$_} } qw(rounding link_project project_id);
176 $convert_params{default_part_id} = $self->data->{part_id};
179 foreach my $related_order_id (keys %$time_recordings_by_order_id) {
180 my $related_order = $orders_by_order_id->{$related_order_id};
183 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_order_id->{$related_order_id}, related_order => $related_order, %convert_params);
186 $::lxdebug->message(LXDebug->WARN(),
187 "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
188 push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}};
192 if (!SL::DB->client->with_transaction(sub {
194 $_->update_attributes(booked => 1) for @{$time_recordings_by_order_id->{$related_order_id}};
196 $related_order->link_to_record($do);
198 # TODO extend link_to_record for items, otherwise long-term no d.r.y.
199 foreach my $item (@{ $do->items }) {
200 foreach (qw(orderitems)) {
201 if ($item->{"converted_from_${_}_id"}) {
202 die unless $item->{id};
203 RecordLinks->create_links('mode' => 'ids',
205 'from_ids' => $item->{"converted_from_${_}_id"},
206 'to_table' => 'delivery_order_items',
207 'to_id' => $item->{id},
209 delete $item->{"converted_from_${_}_id"};
214 # Todo: reduce qty on related order
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 #$project_id = $self->overide_project_id;
236 $project_id = $self->data->{project_id};
237 $project_id ||= $tr->project_id;
238 #$project_id ||= $self->default_project_id;
241 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no project id';
242 $::lxdebug->message(LXDebug->WARN(), $err_msg);
243 push @{ $self->{job_errors} }, $err_msg;
247 my $project = SL::DB::Project->load_cached($project_id);
250 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not found';
251 $::lxdebug->message(LXDebug->WARN(), $err_msg);
252 push @{ $self->{job_errors} }, $err_msg;
255 if (!$project->active || !$project->valid) {
256 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not active or not valid';
257 $::lxdebug->message(LXDebug->WARN(), $err_msg);
258 push @{ $self->{job_errors} }, $err_msg;
261 if ($project->customer_id && $project->customer_id != $tr->customer_id) {
262 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project customer does not match customer of time recording';
263 $::lxdebug->message(LXDebug->WARN(), $err_msg);
264 push @{ $self->{job_errors} }, $err_msg;
270 #$part_id = $self->overide_part_id;
271 $part_id ||= $tr->part_id;
272 #$part_id ||= $self->default_part_id;
273 $part_id ||= $self->data->{part_id};
276 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no part id';
277 $::lxdebug->message(LXDebug->WARN(), $err_msg);
278 push @{ $self->{job_errors} }, $err_msg;
281 my $part = SL::DB::Part->load_cached($part_id);
282 if (!$part->unit_obj->is_time_based) {
283 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : part unit is not time based';
284 $::lxdebug->message(LXDebug->WARN(), $err_msg);
285 push @{ $self->{job_errors} }, $err_msg;
289 my $orders = SL::DB::Manager::Order->get_all(where => [customer_id => $tr->customer_id,
290 or => [quotation => undef, quotation => 0],
291 globalproject_id => $project_id, ]);
293 foreach my $order (@$orders) {
294 if (any { $_->parts_id == $part_id } @{ $order->items_sorted }) {
295 push @matching_orders, $order;
299 if (1 != scalar @matching_orders) {
300 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no or more than one orders do match';
301 $::lxdebug->message(LXDebug->WARN(), $err_msg);
302 push @{ $self->{job_errors} }, $err_msg;
306 return $matching_orders[0];
312 # from_date: 01.12.2020
313 # to_date: 15.12.2020
314 # customernumbers: [1,2,3]
323 SL::BackgroundJob::ConvertTimeRecordings - Convert time recording
324 entries into delivery orders
328 Get all time recording entries for the given period and customer numbers
329 and create delivery ordes out of that (using
330 C<SL::DB::DeliveryOrder-E<gt>new_from_time_recordings>).
334 Some data can be provided to configure this backgroung job.
335 If there is user data and it cannot be validated the background job
336 returns a error messages.
342 The date from which on time recordings should be collected. It defaults
343 to the first day of the previous month.
345 Example (format depends on your settings):
347 from_date: 01.12.2020
351 The date till which time recordings should be collected. It defaults
352 to the last day of the previous month.
354 Example (format depends on your settings):
358 =item C<customernumbers>
360 An array with the customer numbers for which time recordings should
361 be collected. If not given, time recordings for customers are
362 collected. This is the default.
364 Example (format depends on your settings):
366 customernumbers: [c1,22332,334343]
370 The part id of a time based service which should be used to
371 book the times. If not set the clients config defaults is used.
375 If set the 0 no rounding of the times will be done otherwise
376 the times will be rounded up to th full quarters of an hour,
377 ie. 0.25h 0.5h 0.75h 1.25h ...
378 Defaults to rounding true (1).
380 =item C<link_project>
382 If set the job tries to find a previous Order with the current
383 customer and project number and tries to do as much automatic
384 workflow processing as the UI.
385 Defaults to off. If set to true (1) the job will fail if there
386 is no Sales Orders which qualifies as a predecessor.
387 Conditions for a predeccesor:
389 * Global project_id must match time_recording.project_id OR data.project_id
390 * Customer name must match time_recording.customer_id OR data.customernumbers
391 * The sales order must have at least one or more time related services
392 * The Project needs to be valid and active
394 The job doesn't care if the Sales Order is already delivered or closed.
395 If the sales order is overdelivered some organisational stuff needs to be done.
396 The sales order may also already be closed, ie the amount is fully billed, but
397 the services are not yet fully delivered (simple case: 'Payment in advance').
399 Hint: take a look or extend the job CloseProjectsBelongingToClosedSalesOrder for
400 further automatisation of your organisational needs.
405 Use this project_id instead of the project_id in the time recordings.
411 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>