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);
14 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_project) ],
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_project -> 0|1)
60 # from/to date from data. Defaults to begining and end of last month.
61 # TODO get/set see above
64 # handle errors with a catch handler
66 $from_date = DateTime->from_kivitendo($self->data->{from_date}) if $self->data->{from_date};
67 $to_date = DateTime->from_kivitendo($self->data->{to_date}) if $self->data->{to_date};
69 die "Cannot convert date from string $self->data->{from_date} $self->data->{to_date}\n Details :\n $_"; # not $@
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],
82 with_objects => ['customer']);
84 # no time recordings at all ? -> better exit here before iterating a empty hash
85 # return undef or message unless ref $time_recordings->[0] eq SL::DB::Manager::TimeRecording;
89 if ($self->data->{link_project}) {
90 my %time_recordings_by_order_id;
91 my %orders_by_order_id;
92 foreach my $tr (@$time_recordings) {
93 my $order = $self->get_order_for_time_recording($tr);
95 push @{ $time_recordings_by_order_id{$order->id} }, $tr;
96 $orders_by_order_id{$order->id} ||= $order;
98 @donumbers = $self->convert_with_linking(\%time_recordings_by_order_id, \%orders_by_order_id);
101 @donumbers = $self->convert_without_linking($time_recordings);
104 my $msg = t8('Number of delivery orders created:');
106 $msg .= scalar @donumbers;
108 $msg .= join ', ', @donumbers;
110 # die if errors exists
111 if (@{ $self->{job_errors} }) {
112 $msg .= ' ' . t8('The following errors occurred:');
114 $msg .= join "\n", @{ $self->{job_errors} };
126 sub init_link_project {
131 sub convert_without_linking {
132 my ($self, $time_recordings) = @_;
134 my %time_recordings_by_customer_id;
135 push @{ $time_recordings_by_customer_id{$_->customer_id} }, $_ for @$time_recordings;
137 my %convert_params = map { $_ => $self->data->{$_} } qw(rounding link_project project_id);
138 $convert_params{default_part_id} = $self->data->{part_id};
141 foreach my $customer_id (keys %time_recordings_by_customer_id) {
144 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_customer_id{$customer_id}, %convert_params);
147 $::lxdebug->message(LXDebug->WARN(),
148 "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
149 push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
154 if (!SL::DB->client->with_transaction(sub {
156 $_->update_attributes(booked => 1) for @{$time_recordings_by_customer_id{$customer_id}};
159 $::lxdebug->message(LXDebug->WARN(),
160 "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
161 push @{ $self->{job_errors} }, "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
163 push @donumbers, $do->donumber;
171 sub convert_with_linking {
172 my ($self, $time_recordings_by_order_id, $orders_by_order_id) = @_;
174 my %convert_params = map { $_ => $self->data->{$_} } qw(rounding link_project project_id);
175 $convert_params{default_part_id} = $self->data->{part_id};
178 foreach my $related_order_id (keys %$time_recordings_by_order_id) {
179 my $related_order = $orders_by_order_id->{$related_order_id};
182 $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_order_id->{$related_order_id}, related_order => $related_order, %convert_params);
185 $::lxdebug->message(LXDebug->WARN(),
186 "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
187 push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}};
191 if (!SL::DB->client->with_transaction(sub {
193 $_->update_attributes(booked => 1) for @{$time_recordings_by_order_id->{$related_order_id}};
195 $related_order->link_to_record($do);
197 # TODO extend link_to_record for items, otherwise long-term no d.r.y.
198 foreach my $item (@{ $do->items }) {
199 foreach (qw(orderitems)) {
200 if ($item->{"converted_from_${_}_id"}) {
201 die unless $item->{id};
202 RecordLinks->create_links('mode' => 'ids',
204 'from_ids' => $item->{"converted_from_${_}_id"},
205 'to_table' => 'delivery_order_items',
206 'to_id' => $item->{id},
208 delete $item->{"converted_from_${_}_id"};
213 # Todo: reduce qty on related order
217 $::lxdebug->message(LXDebug->WARN(),
218 "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}});
219 push @{ $self->{job_errors} }, "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_order_id->{$related_order_id}};
221 push @donumbers, $do->donumber;
229 sub get_order_for_time_recording {
230 my ($self, $tr) = @_;
234 #$project_id = $self->overide_project_id;
235 $project_id = $self->data->{project_id};
236 $project_id ||= $tr->project_id;
237 #$project_id ||= $self->default_project_id;
240 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no project id';
241 $::lxdebug->message(LXDebug->WARN(), $err_msg);
242 push @{ $self->{job_errors} }, $err_msg;
246 my $project = SL::DB::Project->load_cached($project_id);
249 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not found';
250 $::lxdebug->message(LXDebug->WARN(), $err_msg);
251 push @{ $self->{job_errors} }, $err_msg;
254 if (!$project->active || !$project->valid) {
255 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project not active or not valid';
256 $::lxdebug->message(LXDebug->WARN(), $err_msg);
257 push @{ $self->{job_errors} }, $err_msg;
260 if ($project->customer_id && $project->customer_id != $tr->customer_id) {
261 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : project customer does not match customer of time recording';
262 $::lxdebug->message(LXDebug->WARN(), $err_msg);
263 push @{ $self->{job_errors} }, $err_msg;
269 #$part_id = $self->overide_part_id;
270 $part_id ||= $tr->part_id;
271 #$part_id ||= $self->default_part_id;
272 $part_id ||= $self->data->{part_id};
275 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no part id';
276 $::lxdebug->message(LXDebug->WARN(), $err_msg);
277 push @{ $self->{job_errors} }, $err_msg;
280 my $part = SL::DB::Part->load_cached($part_id);
281 if (!$part->unit_obj->is_time_based) {
282 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : part unit is not time based';
283 $::lxdebug->message(LXDebug->WARN(), $err_msg);
284 push @{ $self->{job_errors} }, $err_msg;
288 my $orders = SL::DB::Manager::Order->get_all(where => [customer_id => $tr->customer_id,
289 or => [quotation => undef, quotation => 0],
290 globalproject_id => $project_id, ]);
292 foreach my $order (@$orders) {
293 if (any { $_->parts_id == $part_id } @{ $order->items_sorted }) {
294 push @matching_orders, $order;
298 if (1 != scalar @matching_orders) {
299 my $err_msg = 'ConvertTimeRecordings: searching related order failed for time recording id ' . $tr->id . ' : no or more than one orders do match';
300 $::lxdebug->message(LXDebug->WARN(), $err_msg);
301 push @{ $self->{job_errors} }, $err_msg;
305 return $matching_orders[0];
311 # from_date: 01.12.2020
312 # to_date: 15.12.2020
313 # customernumbers: [1,2,3]
322 SL::BackgroundJob::ConvertTimeRecordings - Convert time recording
323 entries into delivery orders
327 Get all time recording entries for the given period and customer numbers
328 and create delivery ordes out of that (using
329 C<SL::DB::DeliveryOrder-E<gt>new_from_time_recordings>).
333 Some data can be provided to configure this backgroung job.
334 If there is user data and it cannot be validated the background job
335 returns a error messages.
341 The date from which on time recordings should be collected. It defaults
342 to the first day of the previous month.
344 Example (format depends on your settings):
346 from_date: 01.12.2020
350 The date till which time recordings should be collected. It defaults
351 to the last day of the previous month.
353 Example (format depends on your settings):
357 =item C<customernumbers>
359 An array with the customer numbers for which time recordings should
360 be collected. If not given, time recordings for customers are
361 collected. This is the default.
363 Example (format depends on your settings):
365 customernumbers: [c1,22332,334343]
369 The part id of a time based service which should be used to
370 book the times. If not set the clients config defaults is used.
374 If set the 0 no rounding of the times will be done otherwise
375 the times will be rounded up to th full quarters of an hour,
376 ie. 0.25h 0.5h 0.75h 1.25h ...
377 Defaults to rounding true (1).
379 =item C<link_project>
381 If set the job tries to find a previous Order with the current
382 customer and project number and tries to do as much automatic
383 workflow processing as the UI.
384 Defaults to off. If set to true (1) the job will fail if there
385 is no Sales Orders which qualifies as a predecessor.
386 Conditions for a predeccesor:
388 * Global project_id must match time_recording.project_id OR data.project_id
389 * Customer name must match time_recording.customer_id OR data.customernumbers
390 * The sales order must have at least one or more time related services
391 * The Project needs to be valid and active
393 The job doesn't care if the Sales Order is already delivered or closed.
394 If the sales order is overdelivered some organisational stuff needs to be done.
395 The sales order may also already be closed, ie the amount is fully billed, but
396 the services are not yet fully delivered (simple case: 'Payment in advance').
398 Hint: take a look or extend the job CloseProjectsBelongingToClosedSalesOrder for
399 further automatisation of your organisational needs.
404 Use this project_id instead of the project_id in the time recordings.
410 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>