Merge branch 'b-3.6.1' of ../kivitendo-erp_20220811
[kivitendo-erp.git] / SL / Controller / CsvImport / Inventory.pm
1 package SL::Controller::CsvImport::Inventory;
2
3
4 use strict;
5
6 use SL::Helper::Csv;
7 use SL::Helper::DateTime;
8
9 use SL::DBUtils;
10 use SL::DB::Inventory;
11 use SL::DB::Part;
12 use SL::DB::Warehouse;
13 use SL::DB::Bin;
14 use SL::DB::TransferType;
15 use SL::DB::Employee;
16
17 use parent qw(SL::Controller::CsvImport::Base);
18
19
20 use Rose::Object::MakeMethods::Generic
21 (
22  'scalar --get_set_init' => [ qw(settings parts_by warehouses_by bins_by) ],
23 );
24
25
26 sub init_class {
27   my ($self) = @_;
28   $self->class('SL::DB::Inventory');
29 }
30
31 sub set_profile_defaults {
32 };
33
34 sub init_profile {
35   my ($self) = @_;
36
37   my $profile = $self->SUPER::init_profile;
38   delete @{$profile}{qw(trans_id oe_id delivery_order_items_stock_id trans_type_id project_id)};
39   delete @{$profile}{qw(bestbefore)}    if !$::instance_conf->get_show_bestbefore;
40
41   return $profile;
42 }
43
44 sub init_settings {
45   my ($self) = @_;
46
47   return { map { ( $_ => $self->controller->profile->get($_) ) } qw(warehouse apply_warehouse
48                                                                     bin       apply_bin
49                                                                     comment   apply_comment) };
50 }
51
52 sub init_parts_by {
53   my ($self) = @_;
54
55   my $all_parts = SL::DB::Manager::Part->get_all;
56   return { map { my $col = $_; ( $col =>
57          { map { ( $_->$col => $_ ) } grep { defined $_->$col } @{ $all_parts } } ) } qw(id partnumber ean description) };
58 }
59
60 sub init_warehouses_by {
61   my ($self) = @_;
62
63   my $all_warehouses = SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef ]]);
64   return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $all_warehouses } } ) } qw(id description) };
65 }
66
67 sub init_bins_by {
68   my ($self) = @_;
69
70   my $all_bins = SL::DB::Manager::Bin->get_all();
71   my $bins_by;
72   $bins_by->{_wh_id_and_id_ident()}          = { map { ( _wh_id_and_id_maker($_->warehouse_id, $_->id)                   => $_ ) } @{ $all_bins } };
73   $bins_by->{_wh_id_and_description_ident()} = { map { ( _wh_id_and_description_maker($_->warehouse_id, $_->description) => $_ ) } @{ $all_bins } };
74   return $bins_by;
75 }
76
77 sub check_objects {
78   my ($self) = @_;
79
80   $self->controller->track_progress(phase => 'building data', progress => 0);
81
82   my $i = 0;
83   my $num_data = scalar @{ $self->controller->data };
84   foreach my $entry (@{ $self->controller->data }) {
85     $self->controller->track_progress(progress => $i/$num_data * 100) if $i % 100 == 0;
86
87     $self->check_warehouse($entry);
88     $self->check_bin($entry);
89     $self->check_part($entry);
90     $self->check_qty($entry)            unless scalar @{ $entry->{errors} };
91     $self->handle_comment($entry);
92     $self->handle_employee($entry);
93     $self->handle_transfer_type($entry) unless scalar @{ $entry->{errors} };
94     $self->handle_shippingdate($entry);
95   } continue {
96     $i++;
97   }
98
99   $self->add_info_columns(qw(warehouse bin partnumber employee target_qty));
100 }
101
102 sub setup_displayable_columns {
103   my ($self) = @_;
104
105   $self->SUPER::setup_displayable_columns;
106
107   $self->add_displayable_columns({ name => 'bin',          description => $::locale->text('Bin')                     },
108                                  { name => 'bin_id',       description => $::locale->text('Bin (database ID)')       },
109                                  { name => 'chargenumber', description => $::locale->text('Charge number')           },
110                                  { name => 'comment',      description => $::locale->text('Comment')                 },
111                                  { name => 'employee_id',  description => $::locale->text('Employee (database ID)')  },
112                                  { name => 'partnumber',   description => $::locale->text('Part Number')             },
113                                  { name => 'parts_id',     description => $::locale->text('Part (database ID)')      },
114                                  { name => 'qty',          description => $::locale->text('qty (to transfer)')       },
115                                  { name => 'shippingdate', description => $::locale->text('Shipping date')           },
116                                  { name => 'target_qty',   description => $::locale->text('Target Qty')              },
117                                  { name => 'warehouse',    description => $::locale->text('Warehouse')               },
118                                  { name => 'warehouse_id', description => $::locale->text('Warehouse (database ID)') },
119                                 );
120   if ($::instance_conf->get_show_bestbefore) {
121     $self->add_displayable_columns({ name => 'bestbefore', description => $::locale->text('Best Before') });
122   }
123 }
124
125 sub check_warehouse {
126   my ($self, $entry) = @_;
127
128   my $object = $entry->{object};
129
130   $self->settings->{apply_warehouse} ||= '';  # avoid warnings if undefined
131
132   # If warehouse from front-end is enforced for all transfers, use this, if valid.
133   if ($self->settings->{apply_warehouse} eq 'all') {
134     $object->warehouse_id(undef);
135     my $wh = $self->warehouses_by->{description}->{ $self->settings->{warehouse} };
136     if (!$wh) {
137       push @{ $entry->{errors} }, $::locale->text('Error: Invalid warehouse');
138       return 0;
139     }
140
141     $object->warehouse_id($wh->id);
142   }
143
144   # If warehouse from front-end is enforced for transfers with missing warehouse, use this, if valid.
145   if (    $self->settings->{apply_warehouse} eq 'missing'
146        && ! $object->warehouse_id
147        && ! $entry->{raw_data}->{warehouse} ) {
148     my $wh = $self->warehouses_by->{description}->{ $self->settings->{warehouse} };
149     if (!$wh) {
150       push @{ $entry->{errors} }, $::locale->text('Error: Invalid warehouse');
151       return 0;
152     }
153
154     $object->warehouse_id($wh->id);
155   }
156
157   # Check whether or not warehouse ID is valid.
158   if ($object->warehouse_id && !$self->warehouses_by->{id}->{ $object->warehouse_id }) {
159     push @{ $entry->{errors} }, $::locale->text('Error: Invalid warehouse');
160     return 0;
161   }
162
163   # Map description to ID if given.
164   if (!$object->warehouse_id && $entry->{raw_data}->{warehouse}) {
165     my $wh = $self->warehouses_by->{description}->{ $entry->{raw_data}->{warehouse} };
166     if (!$wh) {
167       push @{ $entry->{errors} }, $::locale->text('Error: Invalid warehouse');
168       return 0;
169     }
170
171     $object->warehouse_id($wh->id);
172   }
173
174   if ($object->warehouse_id) {
175     $entry->{info_data}->{warehouse} = $self->warehouses_by->{id}->{ $object->warehouse_id }->description;
176   } else {
177     push @{ $entry->{errors} }, $::locale->text('Error: Warehouse not found');
178     return 0;
179   }
180
181   return 1;
182 }
183
184 # Check bin for given warehouse, so check_warehouse must be called first.
185 sub check_bin {
186   my ($self, $entry) = @_;
187
188   my $object = $entry->{object};
189
190   $self->settings->{apply_bin} ||= '';  # avoid warnings if undefined
191
192   # If bin from front-end is enforced for all transfers, use this, if valid.
193   if ($self->settings->{apply_bin} eq 'all') {
194     $object->bin_id(undef);
195     my $bin = $self->bins_by->{_wh_id_and_description_ident()}->{ _wh_id_and_description_maker($object->warehouse_id, $self->settings->{bin}) };
196     if (!$bin) {
197       push @{ $entry->{errors} }, $::locale->text('Error: Invalid bin');
198       return 0;
199     }
200
201     $object->bin_id($bin->id);
202   }
203
204   # If bin from front-end is enforced for transfers with missing bin, use this, if valid.
205   if (    $self->settings->{apply_bin} eq 'missing'
206        && ! $object->bin_id
207        && ! $entry->{raw_data}->{bin} ) {
208     my $bin = $self->bins_by->{_wh_id_and_description_ident()}->{ _wh_id_and_description_maker($object->warehouse_id, $self->settings->{bin}) };
209     if (!$bin) {
210       push @{ $entry->{errors} }, $::locale->text('Error: Invalid bin');
211       return 0;
212     }
213
214     $object->bin_id($bin->id);
215   }
216
217   # Check whether or not bin ID is valid.
218   if ($object->bin_id && !$self->bins_by->{_wh_id_and_id_ident()}->{ _wh_id_and_id_maker($object->warehouse_id, $object->bin_id) }) {
219     push @{ $entry->{errors} }, $::locale->text('Error: Invalid bin');
220     return 0;
221   }
222
223   # Map description to ID if given.
224   if (!$object->bin_id && $entry->{raw_data}->{bin} && $object->warehouse_id) {
225     my $bin = $self->bins_by->{_wh_id_and_description_ident()}->{ _wh_id_and_description_maker($object->warehouse_id, $entry->{raw_data}->{bin}) };
226     if (!$bin) {
227       push @{ $entry->{errors} }, $::locale->text('Error: Invalid bin');
228       return 0;
229     }
230
231     $object->bin_id($bin->id);
232   }
233
234   if ($object->bin_id) {
235     $entry->{info_data}->{bin} = $self->bins_by->{_wh_id_and_id_ident()}->{ _wh_id_and_id_maker($object->warehouse_id, $object->bin_id) }->description;
236   } else {
237     push @{ $entry->{errors} }, $::locale->text('Error: Bin not found');
238     return 0;
239   }
240
241   return 1;
242 }
243
244 sub check_part {
245   my ($self, $entry) = @_;
246
247   my $object = $entry->{object};
248
249   # Check whether or not part ID is valid.
250   if ($object->parts_id && !$self->parts_by->{id}->{ $object->parts_id }) {
251     push @{ $entry->{errors} }, $::locale->text('Error: Invalid part');
252     return 0;
253   }
254
255   # Map number to ID if given.
256   if (!$object->parts_id && $entry->{raw_data}->{partnumber}) {
257     my $part = $self->parts_by->{partnumber}->{ $entry->{raw_data}->{partnumber} };
258     if (!$part) {
259       push @{ $entry->{errors} }, $::locale->text('Error: Invalid part');
260       return 0;
261     }
262
263     $object->parts_id($part->id);
264   }
265
266   if ($object->parts_id) {
267     $entry->{info_data}->{partnumber} = $self->parts_by->{id}->{ $object->parts_id }->partnumber;
268   } else {
269     push @{ $entry->{errors} }, $::locale->text('Error: Part not found');
270     return 0;
271   }
272
273   return 1;
274 }
275
276 # This imports inventories when target_qty is given, transfers else.
277 # So we get the actual qty in stock and transfer the difference in case of
278 # a given target_qty
279 sub check_qty {
280   my ($self, $entry) = @_;
281
282   my $object = $entry->{object};
283
284   # parse qty (may be float values)
285   if (exists $entry->{raw_data}->{target_qty}) {
286     $entry->{raw_data}->{target_qty} = $::form->parse_amount(\%::myconfig, $entry->{raw_data}->{target_qty});
287     # $object->target_qty($entry->{raw_data}->{target_qty});
288   }
289   if (exists $entry->{raw_data}->{qty}) {
290     $entry->{raw_data}->{qty}        = $::form->parse_amount(\%::myconfig, $entry->{raw_data}->{qty});
291     $object->qty($entry->{raw_data}->{qty});
292   }
293   if (! exists $entry->{raw_data}->{target_qty} && ! exists $entry->{raw_data}->{qty}) {
294     push @{ $entry->{errors} }, $::locale->text('Error: A quantity or a target quantity must be given.');
295     return 0;
296   }
297
298   if (exists $entry->{raw_data}->{target_qty} && exists $entry->{raw_data}->{qty}) {
299     push @{ $entry->{errors} }, $::locale->text('Error: A quantity and a target quantity could not be given both.');
300     return 0;
301   }
302
303   if (exists $entry->{raw_data}->{target_qty} && ($entry->{raw_data}->{target_qty} * 1) < 0) {
304     push @{ $entry->{errors} }, $::locale->text('Error: A negative target quantity is not allowed.');
305     return 0;
306   }
307
308   # Actual quantity is read from stock or is the result of transfers for the
309   # same part, warehouse, bin, chargenumber and bestbefore date (if
310   # show_bestbefore is enabled) done before.
311   my $key = join '+', $object->parts_id, $object->warehouse_id, $object->bin_id, $object->chargenumber;
312   $key   .= join '+', $key, $object->bestbefore    if $::instance_conf->get_show_bestbefore;
313
314   if (!exists $self->{resulting_quantities}->{$key}) {
315     $self->{resulting_quantities}->{$key} = _get_stocked_qty($object);
316   }
317   my $actual_qty = $self->{resulting_quantities}->{$key};
318
319   if (exists $entry->{raw_data}->{target_qty}) {
320     my $target_qty = $entry->{raw_data}->{target_qty} * 1;
321
322     $object->qty($target_qty - $actual_qty);
323     $self->add_columns(qw(qty));
324   }
325
326   if ($object->qty == 0) {
327     push @{ $entry->{errors} }, $::locale->text('Error: Quantity to transfer is zero.');
328     return 0;
329   }
330
331   # Check if resulting quantity is below zero.
332   if ( ($actual_qty + $object->qty) < 0 ) {
333     push @{ $entry->{errors} }, $::locale->text('Error: Transfer would result in a negative target quantity.');
334     return 0;
335   }
336
337   $self->{resulting_quantities}->{$key} += $object->qty;
338   $entry->{info_data}->{target_qty} = $self->{resulting_quantities}->{$key};
339
340   return 1;
341 }
342
343 sub handle_comment {
344   my ($self, $entry) = @_;
345
346   my $object = $entry->{object};
347
348   # If comment from front-end is enforced for all transfers, use this, if valid.
349   if ($self->settings->{apply_comment} eq 'all') {
350     $object->comment($self->settings->{comment});
351   }
352
353   # If comment from front-end is enforced for transfers with missing comment, use this, if valid.
354   if ($self->settings->{apply_comment} eq 'missing' && ! $object->comment) {
355     $object->comment($self->settings->{comment});
356   }
357
358   return;
359 }
360
361 sub handle_transfer_type  {
362   my ($self, $entry) = @_;
363
364   my $object = $entry->{object};
365
366   my $transfer_type = SL::DB::Manager::TransferType->find_by(description => 'correction',
367                                                              direction   => ($object->qty > 0)? 'in': 'out');
368   $object->trans_type($transfer_type);
369
370   return;
371 }
372
373 # ToDo: employee by name
374 sub handle_employee {
375   my ($self, $entry) = @_;
376
377   my $object = $entry->{object};
378
379   # employee from front end if not given
380   if (!$object->employee_id) {
381     $object->employee_id($self->controller->{employee_id})
382   }
383
384   # employee from login if not given
385   if (!$object->employee_id) {
386     $object->employee_id(SL::DB::Manager::Employee->current->id) if SL::DB::Manager::Employee->current;
387   }
388
389   if ($object->employee_id) {
390     $entry->{info_data}->{employee} = $object->employee->name;
391   }
392
393 }
394
395 sub handle_shippingdate {
396   my ($self, $entry) = @_;
397
398   my $object = $entry->{object};
399
400   if (!$object->shippingdate) {
401     $object->shippingdate(DateTime->today_local);
402   }
403 }
404
405 sub save_objects {
406   my ($self, %params) = @_;
407
408   my $data = $params{data} || $self->controller->data;
409
410   foreach my $entry (@{ $data }) {
411     my ($trans_id) = selectrow_query($::form,$entry->{object}->db->dbh, qq|SELECT nextval('id')|);
412     $entry->{object}->trans_id($trans_id);
413   }
414
415   $self->SUPER::save_objects(%params);
416 }
417
418 sub _get_stocked_qty {
419   my ($object) = @_;
420
421   my $bestbefore_filter  = '';
422   my $bestbefore_val_cnt = 0;
423   if ($::instance_conf->get_show_bestbefore) {
424     $bestbefore_filter  = ($object->bestbefore) ? 'AND bestbefore = ?' : 'AND bestbefore IS NULL';
425     $bestbefore_val_cnt = ($object->bestbefore) ? 1                    : 0;
426   }
427
428   my $query = <<SQL;
429     SELECT sum(qty) FROM inventory
430       WHERE parts_id = ? AND warehouse_id = ? AND bin_id = ? AND chargenumber = ? $bestbefore_filter
431       GROUP BY warehouse_id, bin_id, chargenumber
432 SQL
433
434   my @values = ($object->parts_id,
435                 $object->warehouse_id,
436                 $object->bin_id,
437                 $object->chargenumber);
438   push @values, $object->bestbefore if $bestbefore_val_cnt;
439
440   my ($stocked_qty) = selectrow_query($::form, $object->db->dbh, $query, @values);
441
442   return $stocked_qty;
443 }
444
445 sub _wh_id_and_description_ident {
446   return 'wh_id+description';
447 }
448
449 sub _wh_id_and_description_maker {
450   return join '+', $_[0], $_[1]
451 }
452
453 sub _wh_id_and_id_ident {
454   return 'wh_id+id';
455 }
456
457 sub _wh_id_and_id_maker {
458   return join '+', $_[0], $_[1]
459 }
460
461 1;