e65fb497e4eab58d86c8d2de842cc8531b2c26cb
[kivitendo-erp.git] / SL / BackgroundJob / ConvertTimeRecordings.pm
1 package SL::BackgroundJob::ConvertTimeRecordings;
2
3 use strict;
4
5 use parent qw(SL::BackgroundJob::Base);
6
7 use SL::DB::DeliveryOrder;
8 use SL::DB::TimeRecording;
9
10 use SL::Locale::String qw(t8);
11
12 use Carp;
13 use DateTime;
14 use Try::Tiny;
15
16 sub create_job {
17   $_[0]->create_standard_job('7 3 1 * *'); # every first day of month at 03:07
18 }
19 use Rose::Object::MakeMethods::Generic (
20  'scalar --get_set_init' => [ qw(rounding link_project) ],
21 );
22
23 # valid parameters -> better as class members with rose generic set/get
24 my %valid_params = (
25               from_date => '',
26               to_date   => '',
27               customernumbers => '',
28               part_id => '',
29               rounding => 1,
30               link_project => 0,
31               project_id => '',
32              );
33
34 #
35 # If job does not throw an error,
36 # success in background_job_histories is 'success'.
37 # It is 'failure' otherwise.
38 #
39 # Return value goes to result in background_job_histories.
40 #
41 sub run {
42   my ($self, $db_obj) = @_;
43
44   my $data;
45   $data = $db_obj->data_as_hash if $db_obj;
46
47   $self->{$_} = [] for qw(job_errors);
48
49   # check user input param names
50   foreach my $param (keys %{ $data }) {
51     die "Not a valid parameter: $param" unless exists $valid_params{$param};
52   }
53
54   # TODO check user input param values - (defaults are assigned later)
55   # 1- If there are any customer numbers check if they refer to valid customers
56   #    otherwise croak and do nothing
57   # 2 .. n Same applies for other params if used at all (rounding -> 0|1  link_project -> 0|1)
58
59   # from/to date from data. Defaults to begining and end of last month.
60   # TODO get/set see above
61   my $from_date;
62   my $to_date;
63   # handle errors with a catch handler
64   try {
65     $from_date   = DateTime->from_kivitendo($data->{from_date}) if $data->{from_date};
66     $to_date     = DateTime->from_kivitendo($data->{to_date})   if $data->{to_date};
67   } catch {
68     die "Cannot convert date from string $data->{from_date} $data->{to_date}\n Details :\n $_"; # not $@
69   };
70   $from_date ||= DateTime->new( day => 1,    month => DateTime->today_local->month, year => DateTime->today_local->year)->subtract(months => 1);
71   $to_date   ||= DateTime->last_day_of_month(month => DateTime->today_local->month, year => DateTime->today_local->year)->subtract(months => 1);
72
73   $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)
74
75   my %customer_where;
76   %customer_where = ('customer.customernumber' => $data->{customernumbers}) if 'ARRAY' eq ref $data->{customernumbers};
77
78   my $time_recordings = SL::DB::Manager::TimeRecording->get_all(where        => [end_time => { ge_lt => [ $from_date, $to_date ]},
79                                                                                  or => [booked => 0, booked => undef],
80                                                                                  %customer_where],
81                                                                 with_objects => ['customer']);
82   # no time recordings at all ? -> better exit here before iterating a empty hash
83   # return undef or message unless ref $time_recordings->[0] eq SL::DB::Manager::TimeRecording;
84   my %time_recordings_by_customer_id;
85   # push @{ $time_recordings_by_customer_id{$_->customer_id} }, $_ for @$time_recordings;
86   # loop over all entries and add default or user defined params:
87
88   for my $source_entry (@$time_recordings) {
89     # set user defaults for processing
90     $source_entry->{$_} = $self->$_ for qw(rounding link_project);
91     foreach (qw(project_id parts_id)) {
92       $source_entry->{$_} = $self->{$_} if length ($self->{$_});
93     }
94     push @{ $time_recordings_by_customer_id{$source_entry->customer_id} }, $source_entry;
95   }
96   my @donumbers;
97   foreach my $customer_id (keys %time_recordings_by_customer_id) {
98     my $do;
99     if (!eval {
100       $do = SL::DB::DeliveryOrder->new_from_time_recordings($time_recordings_by_customer_id{$customer_id});
101       1;
102     }) {
103       $::lxdebug->message(LXDebug->WARN(),
104                           "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
105       push @{ $self->{job_errors} }, "ConvertTimeRecordings: creating delivery order failed ($@) for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
106
107     }
108
109     if ($do) {
110       if (!SL::DB->client->with_transaction(sub {
111         $do->save;
112         $_->update_attributes(booked => 1) for @{$time_recordings_by_customer_id{$customer_id}};
113         1;
114       })) {
115         $::lxdebug->message(LXDebug->WARN(),
116                             "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}});
117         push @{ $self->{job_errors} }, "ConvertTimeRecordings: saving delivery order failed for time recording ids " . join ', ', map { $_->id } @{$time_recordings_by_customer_id{$customer_id}};
118       } else {
119         push @donumbers, $do->donumber;
120       }
121     }
122   }
123
124   my $msg  = t8('Number of delivery orders created:');
125   $msg    .= ' ';
126   $msg    .= scalar @donumbers;
127   $msg    .= ' (';
128   $msg    .= join ', ', @donumbers;
129   $msg    .= ').';
130   # die if errors exists
131   if (@{ $self->{job_errors} }) {
132     $msg  .= ' ' . t8('The following errors occurred:');
133     $msg  .= ' ';
134     $msg  .= join "\n", @{ $self->{job_errors} };
135     die $msg . "\n";
136   }
137   return $msg;
138 }
139
140 # inits
141
142 sub init_rounding {
143   1
144 }
145
146 sub init_link_project {
147   0
148 }
149
150 1;
151
152 # possible data
153 # from_date: 01.12.2020
154 # to_date: 15.12.2020
155 # customernumbers: [1,2,3]
156 __END__
157
158 =pod
159
160 =encoding utf8
161
162 =head1 NAME
163
164 SL::BackgroundJob::ConvertTimeRecordings - Convert time recording
165 entries into delivery orders
166
167 =head1 SYNOPSIS
168
169 Get all time recording entries for the given period and customer numbers
170 and create delivery ordes out of that (using
171 C<SL::DB::DeliveryOrder-E<gt>new_from_time_recordings>).
172
173 =head1 CONFIGURATION
174
175 Some data can be provided to configure this backgroung job.
176 If there is user data and it cannot be validated the background job
177 returns a error messages.
178
179 =over 4
180
181 =item C<from_date>
182
183 The date from which on time recordings should be collected. It defaults
184 to the first day of the previous month.
185
186 Example (format depends on your settings):
187
188 from_date: 01.12.2020
189
190 =item C<to_date>
191
192 The date till which time recordings should be collected. It defaults
193 to the last day of the previous month.
194
195 Example (format depends on your settings):
196
197 to_date: 15.12.2020
198
199 =item C<customernumbers>
200
201 An array with the customer numbers for which time recordings should
202 be collected. If not given, time recordings for customers are
203 collected. This is the default.
204
205 Example (format depends on your settings):
206
207 customernumbers: [c1,22332,334343]
208
209 =item C<part_id>
210
211 The part id of a time based service which should be used to
212 book the times. If not set the clients config defaults is used.
213
214 =item C<rounding>
215
216 If set the 0 no rounding of the times will be done otherwise
217 the times will be rounded up to th full quarters of an hour,
218 ie. 0.25h 0.5h 0.75h 1.25h ...
219 Defaults to rounding true (1).
220
221 =item C<link_project>
222
223 If set the job tries to find a previous Order with the current
224 customer and project number and tries to do as much automatic
225 workflow processing as the UI.
226 Defaults to off. If set to true (1) the job will fail if there
227 is no Sales Orders which qualifies as a predecessor.
228 Conditions for a predeccesor:
229
230  * Global project_id must match time_recording.project_id OR data.project_id
231  * Customer name must match time_recording.customer_id OR data.customernumbers
232  * The sales order must have at least one or more time related services
233  * The Project needs to be valid and active
234
235 The job doesn't care if the Sales Order is already delivered or closed.
236 If the sales order is overdelivered some organisational stuff needs to be done.
237 The sales order may also already be closed, ie the amount is fully billed, but
238 the services are not yet fully delivered (simple case: 'Payment in advance').
239
240 Hint: take a look or extend the job CloseProjectsBelongingToClosedSalesOrder for
241 further automatisation of your organisational needs.
242
243
244 =item C<project_id>
245
246 Use this project_id instead of the project_id in the time recordings.
247
248 =back
249
250 =head1 AUTHOR
251
252 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>
253
254 =cut