Inventur: Schwellwert in Mandantenkonfig. für Warnung bei Mengenabweichung
[kivitendo-erp.git] / SL / Controller / Inventory.pm
1 package SL::Controller::Inventory;
2
3 use strict;
4 use warnings;
5 use POSIX qw(strftime);
6
7 use parent qw(SL::Controller::Base);
8
9 use SL::DB::Inventory;
10 use SL::DB::Stocktaking;
11 use SL::DB::Part;
12 use SL::DB::Warehouse;
13 use SL::DB::Unit;
14 use SL::DB::Default;
15 use SL::WH;
16 use SL::ReportGenerator;
17 use SL::Locale::String qw(t8);
18 use SL::Presenter::Tag qw(select_tag);
19 use SL::DBUtils;
20 use SL::Helper::Flash;
21 use SL::Controller::Helper::ReportGenerator;
22 use SL::Controller::Helper::GetModels;
23
24 use English qw(-no_match_vars);
25
26 use Rose::Object::MakeMethods::Generic (
27   'scalar --get_set_init' => [ qw(warehouses units is_stocktaking stocktaking_models stocktaking_cutoff_date) ],
28   'scalar'                => [ qw(warehouse bin unit part) ],
29 );
30
31 __PACKAGE__->run_before('_check_auth');
32 __PACKAGE__->run_before('_check_warehouses');
33 __PACKAGE__->run_before('load_part_from_form',   only => [ qw(stock_in part_changed mini_stock stock stocktaking_part_changed stocktaking_get_warn_qty_threshold save_stocktaking) ]);
34 __PACKAGE__->run_before('load_unit_from_form',   only => [ qw(stock_in part_changed mini_stock stock stocktaking_part_changed stocktaking_get_warn_qty_threshold save_stocktaking) ]);
35 __PACKAGE__->run_before('load_wh_from_form',     only => [ qw(stock_in warehouse_changed stock stocktaking stocktaking_get_warn_qty_threshold save_stocktaking) ]);
36 __PACKAGE__->run_before('load_bin_from_form',    only => [ qw(stock_in stock stocktaking stocktaking_get_warn_qty_threshold save_stocktaking) ]);
37 __PACKAGE__->run_before('set_target_from_part',  only => [ qw(part_changed) ]);
38 __PACKAGE__->run_before('mini_stock',            only => [ qw(stock_in mini_stock) ]);
39 __PACKAGE__->run_before('sanitize_target',       only => [ qw(stock_usage stock_in warehouse_changed part_changed stocktaking stocktaking_part_changed stocktaking_get_warn_qty_threshold save_stocktaking) ]);
40 __PACKAGE__->run_before('set_layout');
41
42 sub action_stock_in {
43   my ($self) = @_;
44
45   $::form->{title}   = t8('Stock');
46
47   $::request->layout->focus('#part_id_name');
48   my $transfer_types = WH->retrieve_transfer_types('in');
49   map { $_->{description} = $main::locale->text($_->{description}) } @{ $transfer_types };
50   $self->setup_stock_in_action_bar;
51   $self->render('inventory/warehouse_selection_stock', title => $::form->{title}, TRANSFER_TYPES => $transfer_types );
52 }
53
54 sub action_stock_usage {
55   my ($self) = @_;
56
57   $::form->{title}   = t8('UsageE');
58
59   $::form->get_lists('warehouses' => { 'key'    => 'WAREHOUSES',
60                                        'bins'   => 'BINS', });
61   $::request->layout->use_javascript("${_}.js") for qw(kivi.PartsWarehouse);
62
63   $self->setup_stock_usage_action_bar;
64   $self->render('inventory/warehouse_usage',
65                 title => $::form->{title},
66                 year => DateTime->today->year,
67                 WAREHOUSES => $::form->{WAREHOUSES},
68                 WAREHOUSE_FILTER => 1,
69                 warehouse_id => 0,
70                 bin_id => 0
71       );
72
73 }
74
75 sub getnumcolumns {
76   my ($self) = @_;
77   return qw(stock incorrection found insum back outcorrection disposed
78                      missing shipped used outsum consumed averconsumed);
79 }
80
81 sub action_usage {
82   my ($self) = @_;
83
84   $main::lxdebug->enter_sub();
85
86   my $form     = $main::form;
87   my %myconfig = %main::myconfig;
88   my $locale   = $main::locale;
89
90   $form->{title}   = t8('UsageE');
91   $form->{report_generator_output_format} = 'HTML' if !$form->{report_generator_output_format};
92
93   my $report = SL::ReportGenerator->new(\%myconfig, $form);
94
95   my @columns = qw(partnumber partdescription);
96
97   push @columns , qw(ptype unit) if $form->{report_generator_output_format} eq 'HTML';
98
99   my @numcolumns = qw(stock incorrection found insum back outcorrection disposed
100                      missing shipped used outsum consumed averconsumed);
101
102   push @columns , $self->getnumcolumns();
103
104   my @hidden_variables = qw(reporttype year duetyp fromdate todate
105                             warehouse_id bin_id partnumber description bestbefore chargenumber partstypes_id);
106   my %column_defs = (
107     'partnumber'      => { 'text' => $locale->text('Part Number'), },
108     'partdescription' => { 'text' => $locale->text('Part_br_Description'), },
109     'unit'            => { 'text' => $locale->text('Unit'), },
110     'stock'           => { 'text' => $locale->text('stock_br'), },
111     'incorrection'    => { 'text' => $locale->text('correction_br'), },
112     'found'           => { 'text' => $locale->text('found_br'), },
113     'insum'           => { 'text' => $locale->text('sum'), },
114     'back'            => { 'text' => $locale->text('back_br'), },
115     'outcorrection'   => { 'text' => $locale->text('correction_br'), },
116     'disposed'        => { 'text' => $locale->text('disposed_br'), },
117     'missing'         => { 'text' => $locale->text('missing_br'), },
118     'shipped'         => { 'text' => $locale->text('shipped_br'), },
119     'used'            => { 'text' => $locale->text('used_br'), },
120     'outsum'          => { 'text' => $locale->text('sum'), },
121     'consumed'        => { 'text' => $locale->text('consumed'), },
122     'averconsumed'    => { 'text' => $locale->text('averconsumed_br'), },
123   );
124
125
126   map { $column_defs{$_}->{visible} = 1 } @columns;
127   #map { $column_defs{$_}->{visible} = $form->{"l_${_}"} ? 1 : 0 } @columns;
128   map { $column_defs{$_}->{align} = 'right' } @numcolumns;
129
130   my @custom_headers = ();
131   # Zeile 1:
132   push @custom_headers, [
133       { 'text' => $locale->text('Part'),
134         'colspan' => ($form->{report_generator_output_format} eq 'HTML'?4:2), 'align' => 'center'},
135       { 'text' => $locale->text('Into bin'), 'colspan' => 4, 'align' => 'center'},
136       { 'text' => $locale->text('From bin'), 'colspan' => 7, 'align' => 'center'},
137       { 'text' => $locale->text('UsageWithout'),    'colspan' => 2, 'align' => 'center'},
138   ];
139
140   # Zeile 2:
141   my @line_2 = ();
142   map { push @line_2 , $column_defs{$_} } @columns;
143   push @custom_headers, [ @line_2 ];
144
145   $report->set_custom_headers(@custom_headers);
146   $report->set_columns( %column_defs );
147   $report->set_column_order(@columns);
148
149   $report->set_export_options('usage', @hidden_variables );
150
151   $report->set_sort_indicator($form->{sort}, $form->{order});
152   $report->set_options('output_format'        => 'HTML',
153                        'controller_class'     => 'Inventory',
154                        'title'                => $form->{title},
155 #                      'html_template'        => 'inventory/usage_report',
156                        'attachment_basename'  => strftime($locale->text('warehouse_usage_list') . '_%Y%m%d', localtime time));
157   $report->set_options_from_form;
158
159   my %searchparams ;
160 # form vars
161 #   reporttype = custom
162 #   year = 2014
163 #   duetyp = 7
164
165   my $start       = DateTime->now_local;
166   my $end         = DateTime->now_local;
167   my $actualepoch = $end->epoch();
168   my $days = 365;
169   my $mdays=30;
170   $searchparams{reporttype} = $form->{reporttype};
171   if ($form->{reporttype} eq "custom") {
172     my $smon = 1;
173     my $emon = 12;
174     my $sday = 1;
175     my $eday = 31;
176     #forgotten the year --> thisyear
177     if ($form->{year} !~ m/^\d\d\d\d$/) {
178       $locale->date(\%myconfig, $form->current_date(\%myconfig), 0) =~
179         /(\d\d\d\d)/;
180       $form->{year} = $1;
181     }
182     my $leapday = ($form->{year} % 4 == 0) ? 1:0;
183     #yearly report
184     if ($form->{duetyp} eq "13") {
185         $days += $leapday;
186     }
187
188     #Quater reports
189     if ($form->{duetyp} eq "A") {
190       $emon = 3;
191       $days = 90 + $leapday;
192     }
193     if ($form->{duetyp} eq "B") {
194       $smon = 4;
195       $emon = 6;
196       $eday = 30;
197       $days = 91;
198     }
199     if ($form->{duetyp} eq "C") {
200       $smon = 7;
201       $emon = 9;
202       $eday = 30;
203       $days = 92;
204     }
205     if ($form->{duetyp} eq "D") {
206       $smon = 10;
207       $days = 92;
208     }
209     #Monthly reports
210     if ($form->{duetyp} eq "1" || $form->{duetyp} eq "3" || $form->{duetyp} eq "5" ||
211         $form->{duetyp} eq "7" || $form->{duetyp} eq "8" || $form->{duetyp} eq "10" ||
212         $form->{duetyp} eq "12") {
213         $smon = $emon = $form->{duetyp}*1;
214         $mdays=$days = 31;
215     }
216     if ($form->{duetyp} eq "2" || $form->{duetyp} eq "4" || $form->{duetyp} eq "6" ||
217         $form->{duetyp} eq "9" || $form->{duetyp} eq "11" ) {
218         $smon = $emon = $form->{duetyp}*1;
219         $eday = 30;
220         if ($form->{duetyp} eq "2" ) {
221             #this works from 1901 to 2099, 1900 and 2100 fail.
222             $eday = ($form->{year} % 4 == 0) ? 29 : 28;
223         }
224         $mdays=$days = $eday;
225     }
226     $searchparams{year} = $form->{year};
227     $searchparams{duetyp} = $form->{duetyp};
228     $start->set_month($smon);
229     $start->set_day($sday);
230     $start->set_year($form->{year}*1);
231     $end->set_month($emon);
232     $end->set_day($eday);
233     $end->set_year($form->{year}*1);
234   }  else {
235     $searchparams{fromdate} = $form->{fromdate};
236     $searchparams{todate} = $form->{todate};
237 #   reporttype = free
238 #   fromdate = 01.01.2014
239 #   todate = 31.05.2014
240     my ($yy, $mm, $dd) = $locale->parse_date(\%myconfig,$form->{fromdate});
241     $start->set_year($yy);
242     $start->set_month($mm);
243     $start->set_day($dd);
244     ($yy, $mm, $dd) = $locale->parse_date(\%myconfig,$form->{todate});
245     $end->set_year($yy);
246     $end->set_month($mm);
247     $end->set_day($dd);
248     my $dur = $start->delta_md($end);
249     $days = $dur->delta_months()*30 + $dur->delta_days() ;
250   }
251   $start->set_second(0);
252   $start->set_minute(0);
253   $start->set_hour(0);
254   $end->set_second(59);
255   $end->set_minute(59);
256   $end->set_hour(23);
257   if ( $end->epoch() > $actualepoch ) {
258       $end = DateTime->now_local;
259       my $dur = $start->delta_md($end);
260       $days = $dur->delta_months()*30 + $dur->delta_days() ;
261   }
262   if ( $start->epoch() > $end->epoch() ) { $start = $end;$days = 1;}
263   $days = $mdays if $days < $mdays;
264   #$main::lxdebug->message(LXDebug->DEBUG2(), "start=".$start->epoch());
265   #$main::lxdebug->message(LXDebug->DEBUG2(), "  end=".$end->epoch());
266   #$main::lxdebug->message(LXDebug->DEBUG2(), " days=".$days);
267   my @andfilter = (shippingdate => { ge => $start }, shippingdate => { le => $end } );
268   if ( $form->{warehouse_id} ) {
269       push @andfilter , ( warehouse_id => $form->{warehouse_id});
270       $searchparams{warehouse_id} = $form->{warehouse_id};
271       if ( $form->{bin_id} ) {
272           push @andfilter , ( bin_id => $form->{bin_id});
273           $searchparams{bin_id} = $form->{bin_id};
274       }
275   }
276   # alias class t2 entspricht parts
277   if ( $form->{partnumber} ) {
278       push @andfilter , ( 't2.partnumber' => { ilike => '%'. $form->{partnumber} .'%' });
279       $searchparams{partnumber} = $form->{partnumber};
280   }
281   if ( $form->{description} ) {
282       push @andfilter , ( 't2.description' => { ilike => '%'. $form->{description} .'%'  });
283       $searchparams{description} = $form->{description};
284   }
285   if ( $form->{bestbefore} ) {
286     push @andfilter , ( bestbefore => { eq => $form->{bestbefore} });
287       $searchparams{bestbefore} = $form->{bestbefore};
288   }
289   if ( $form->{chargenumber} ) {
290       push @andfilter , ( chargenumber => { ilike => '%'.$form->{chargenumber}.'%' });
291       $searchparams{chargenumber} = $form->{chargenumber};
292   }
293   if ( $form->{partstypes_id} ) {
294       push @andfilter , ( 't2.partstypes_id' => $form->{partstypes_id} );
295       $searchparams{partstypes_id} = $form->{partstypes_id};
296   }
297
298   my @filter = (and => [ @andfilter ] );
299
300   my $objs = SL::DB::Manager::Inventory->get_all(with_objects => ['parts'], where => [ @filter ] , sort_by => 'parts.partnumber ASC');
301   #my $objs = SL::DB::Inventory->_get_manager_class->get_all(...);
302
303   # manual paginating, yuck
304   my $page = $::form->{page} || 1;
305   my $pages = {};
306   $pages->{per_page}        = $::form->{per_page} || 20;
307   my $first_nr = ($page - 1) * $pages->{per_page};
308   my $last_nr  = $first_nr + $pages->{per_page};
309
310   my $last_partid = 0;
311   my $last_row = { };
312   my $row_ind = 0;
313   my $allrows = 0;
314   $allrows = 1 if $form->{report_generator_output_format} ne 'HTML' ;
315   #$main::lxdebug->message(LXDebug->DEBUG2(), "first_nr=".$first_nr." last_nr=".$last_nr);
316   foreach my $entry (@{ $objs } ) {
317       if ( $entry->parts_id != $last_partid ) {
318           if ( $last_partid > 0 ) {
319               if ( $allrows || ($row_ind >= $first_nr && $row_ind < $last_nr )) {
320                   $self->make_row_result($last_row,$days,$last_partid);
321                   $report->add_data($last_row);
322               }
323               $row_ind++ ;
324           }
325           $last_partid = $entry->parts_id;
326           $last_row = { };
327           $last_row->{partnumber}->{data} = $entry->part->partnumber;
328           $last_row->{partdescription}->{data} = $entry->part->description;
329           $last_row->{unit}->{data} = $entry->part->unit;
330           $last_row->{stock}->{data} = 0;
331           $last_row->{incorrection}->{data} = 0;
332           $last_row->{found}->{data} = 0;
333           $last_row->{back}->{data} = 0;
334           $last_row->{outcorrection}->{data} = 0;
335           $last_row->{disposed}->{data} = 0;
336           $last_row->{missing}->{data} = 0;
337           $last_row->{shipped}->{data} = 0;
338           $last_row->{used}->{data} = 0;
339           $last_row->{insum}->{data} = 0;
340           $last_row->{outsum}->{data} = 0;
341           $last_row->{consumed}->{data} = 0;
342           $last_row->{averconsumed}->{data} = 0;
343       }
344       if ( !$allrows && $row_ind >= $last_nr ) {
345           next;
346       }
347       my $prefix='';
348       if ( $entry->trans_type->description eq 'correction' ) {
349           $prefix = $entry->trans_type->direction;
350       }
351       $last_row->{$prefix.$entry->trans_type->description}->{data} +=
352           ( $entry->trans_type->direction eq 'out' ? -$entry->qty : $entry->qty );
353   }
354   if ( $last_partid > 0 && ( $allrows || ($row_ind >= $first_nr && $row_ind < $last_nr ))) {
355       $self->make_row_result($last_row,$days,$last_partid);
356       $report->add_data($last_row);
357       $row_ind++ ;
358   }
359   my $num_rows = @{ $report->{data} } ;
360   #$main::lxdebug->message(LXDebug->DEBUG2(), "count=".$row_ind." rows=".$num_rows);
361
362   if ( ! $allrows ) {
363       $pages->{max}  = SL::DB::Helper::Paginated::ceil($row_ind, $pages->{per_page}) || 1;
364       $pages->{page} = $page < 1 ? 1: $page > $pages->{max} ? $pages->{max}: $page;
365       $pages->{common} = [ grep { $_->{visible} } @{ SL::DB::Helper::Paginated::make_common_pages($pages->{page}, $pages->{max}) } ];
366       $self->{pages} = $pages;
367       $searchparams{action} = "usage";
368       $self->{base_url} = $self->url_for(\%searchparams );
369       #$main::lxdebug->message(LXDebug->DEBUG2(), "page=".$pages->{page}." url=".$self->{base_url});
370
371       $report->set_options('raw_bottom_info_text' => $self->render('inventory/report_bottom', { output => 0 }) );
372   }
373   $report->generate_with_headers();
374
375   $main::lxdebug->leave_sub();
376
377 }
378
379 sub make_row_result {
380   my ($self,$row,$days,$partid) = @_;
381   my $form     = $main::form;
382   my $myconfig = \%main::myconfig;
383
384   $row->{insum}->{data}  = $row->{stock}->{data} + $row->{incorrection}->{data} + $row->{found}->{data};
385   $row->{outsum}->{data} = $row->{back}->{data} + $row->{outcorrection}->{data} + $row->{disposed}->{data} +
386        $row->{missing}->{data} + $row->{shipped}->{data} + $row->{used}->{data};
387   $row->{consumed}->{data} = $row->{outsum}->{data} -
388        $row->{outcorrection}->{data} - $row->{incorrection}->{data};
389   $row->{averconsumed}->{data} = $row->{consumed}->{data}*30/$days ;
390   map { $row->{$_}->{data} = $form->format_amount($myconfig,$row->{$_}->{data},2); } $self->getnumcolumns();
391   $row->{partnumber}->{link} = 'controller.pl?action=Part/edit&part.id' . $partid;
392 }
393
394 sub action_stock {
395   my ($self) = @_;
396
397   my $transfer_error;
398   my $qty = $::form->parse_amount(\%::myconfig, $::form->{qty});
399   if (!$qty) {
400     $transfer_error = t8('Cannot stock without amount');
401   } elsif ($qty < 0) {
402     $transfer_error = t8('Cannot stock negative amounts');
403   } else {
404     # do stock
405     $::form->throw_on_error(sub {
406       eval {
407         WH->transfer({
408           parts         => $self->part,
409           dst_bin       => $self->bin,
410           dst_wh        => $self->warehouse,
411           qty           => $qty,
412           unit          => $self->unit,
413           transfer_type => 'stock',
414           chargenumber  => $::form->{chargenumber},
415           bestbefore    => $::form->{bestbefore},
416           ean           => $::form->{ean},
417           comment       => $::form->{comment},
418         });
419         1;
420       } or do { $transfer_error = $EVAL_ERROR->getMessage; }
421     });
422
423     if (!$transfer_error) {
424       if ($::form->{write_default_bin}) {
425         $self->part->load;   # onhand is calculated in between. don't mess that up
426         $self->part->bin($self->bin);
427         $self->part->warehouse($self->warehouse);
428         $self->part->save;
429       }
430
431       flash_later('info', t8('Transfer successful'));
432     }
433   }
434
435   my %additional_redirect_params = ();
436   if ($transfer_error) {
437     flash_later('error', $transfer_error);
438     $additional_redirect_params{$_}  = $::form->{$_} for qw(qty chargenumber bestbefore ean comment);
439     $additional_redirect_params{qty} = $qty;
440   }
441
442   # redirect
443   $self->redirect_to(
444     action       => 'stock_in',
445     part_id      => $self->part->id,
446     bin_id       => $self->bin->id,
447     warehouse_id => $self->warehouse->id,
448     unit_id      => $self->unit->id,
449     %additional_redirect_params,
450   );
451 }
452
453 sub action_part_changed {
454   my ($self) = @_;
455
456   # no standard? ask user if he wants to write it
457   if ($self->part->id && !$self->part->bin_id && !$self->part->warehouse_id) {
458     $self->js->show('#write_default_bin_span');
459   } else {
460     $self->js->hide('#write_default_bin_span')
461              ->removeAttr('#write_default_bin', 'checked');
462   }
463
464   $self->js
465     ->replaceWith('#warehouse_id', $self->build_warehouse_select)
466     ->replaceWith('#bin_id', $self->build_bin_select)
467     ->replaceWith('#unit_id', $self->build_unit_select)
468     ->focus('#warehouse_id')
469     ->render;
470 }
471
472 sub action_warehouse_changed {
473   my ($self) = @_;
474
475   $self->js
476     ->replaceWith('#bin_id', $self->build_bin_select)
477     ->focus('#bin_id')
478     ->render;
479 }
480
481 sub action_mini_stock {
482   my ($self) = @_;
483
484   $self->js
485     ->html('#stock', $self->render('inventory/_stock', { output => 0 }))
486     ->render;
487 }
488
489 sub action_stocktaking {
490   my ($self) = @_;
491
492   $::request->{layout}->use_javascript("${_}.js") for qw(kivi.Inventory);
493   $::request->layout->focus('#part_id_name');
494   $self->setup_stock_stocktaking_action_bar;
495   $self->render('inventory/stocktaking/form', title => t8('Stocktaking'));
496 }
497
498 sub action_save_stocktaking {
499   my ($self) = @_;
500
501   return $self->js->flash('error', t8('A target quantitiy has to be given'))->render()
502     if $::form->{target_qty} eq '';
503
504   my $target_qty = $::form->parse_amount(\%::myconfig, $::form->{target_qty});
505
506   return $self->js->flash('error', t8('Error: A negative target quantity is not allowed.'))->render()
507     if $target_qty < 0;
508
509   my $stocked_qty  = _get_stocked_qty($self->part,
510                                       warehouse_id => $self->warehouse->id,
511                                       bin_id       => $self->bin->id,
512                                       chargenumber => $::form->{chargenumber},
513                                       bestbefore   => $::form->{bestbefore},);
514
515   my $stocked_qty_in_form_units = $self->part->unit_obj->convert_to($stocked_qty, $self->unit);
516
517   if (!$::form->{dont_check_already_counted}) {
518     my $already_counted = _already_counted($self->part,
519                                            warehouse_id => $self->warehouse->id,
520                                            bin_id       => $self->bin->id,
521                                            cutoff_date  => $::form->{cutoff_date_as_date},
522                                            chargenumber => $::form->{chargenumber},
523                                            bestbefore   => $::form->{bestbefore});
524     if (scalar @$already_counted) {
525       my $reply = $self->js->dialog->open({
526         html   => $self->render('inventory/stocktaking/_already_counted_dialog',
527                                 { output => 0 },
528                                 already_counted           => $already_counted,
529                                 stocked_qty               => $stocked_qty,
530                                 stocked_qty_in_form_units => $stocked_qty_in_form_units),
531         id     => 'already_counted_dialog',
532         dialog => {
533           title => t8('Already counted'),
534         },
535       })->render;
536
537       return $reply;
538     }
539   }
540
541   # - target_qty is in units given in form ($self->unit)
542   # - WH->transfer expects qtys in given unit (here: unit from form (unit -> $self->unit))
543   # Therefore use stocked_qty in form units for calculation.
544   my $qty        = $target_qty - $stocked_qty_in_form_units;
545   my $src_or_dst = $qty < 0? 'src' : 'dst';
546   $qty           = abs($qty);
547
548   my $transfer_error;
549   # do stock
550   $::form->throw_on_error(sub {
551     eval {
552       WH->transfer({
553         parts                   => $self->part,
554         $src_or_dst.'_bin'      => $self->bin,
555         $src_or_dst.'_wh'       => $self->warehouse,
556         qty                     => $qty,
557         unit                    => $self->unit,
558         transfer_type           => 'stocktaking',
559         chargenumber            => $::form->{chargenumber},
560         bestbefore              => $::form->{bestbefore},
561         ean                     => $::form->{ean},
562         comment                 => $::form->{comment},
563         record_stocktaking      => 1,
564         stocktaking_qty         => $target_qty,
565         stocktaking_cutoff_date => $::form->{cutoff_date_as_date},
566       });
567       1;
568     } or do { $transfer_error = $EVAL_ERROR->getMessage; }
569   });
570
571   return $self->js->flash('error', $transfer_error)->render()
572     if $transfer_error;
573
574   flash_later('info', $::locale->text('Part successful counted'));
575   $self->redirect_to(action              => 'stocktaking',
576                      warehouse_id        => $self->warehouse->id,
577                      bin_id              => $self->bin->id,
578                      cutoff_date_as_date => $self->stocktaking_cutoff_date->to_kivitendo);
579 }
580
581 sub action_reload_stocktaking_history {
582   my ($self) = @_;
583
584   $::form->{filter}{'cutoff_date:date'} = $self->stocktaking_cutoff_date->to_kivitendo;
585   $::form->{filter}{'employee_id'}      = SL::DB::Manager::Employee->current->id;
586
587   $self->prepare_stocktaking_report;
588   $self->report_generator_list_objects(report => $self->{report}, objects => $self->stocktaking_models->get, layout => 0, header => 0);
589 }
590
591 sub action_stocktaking_part_changed {
592   my ($self) = @_;
593
594   $self->js
595     ->replaceWith('#unit_id', $self->build_unit_select)
596     ->focus('#target_qty')
597     ->render;
598 }
599
600 sub action_stocktaking_journal {
601   my ($self) = @_;
602
603   $self->prepare_stocktaking_report(full => 1);
604   $self->report_generator_list_objects(report => $self->{report}, objects => $self->stocktaking_models->get);
605 }
606
607 sub action_stocktaking_get_warn_qty_threshold {
608   my ($self) = @_;
609
610   return $_[0]->render(\ !!0, { type => 'text' }) if $::form->{target_qty} eq '';
611   return $_[0]->render(\ !!0, { type => 'text' }) if 0 == $::instance_conf->get_stocktaking_qty_threshold;
612
613   my $target_qty  = $::form->parse_amount(\%::myconfig, $::form->{target_qty});
614   my $stocked_qty = _get_stocked_qty($self->part,
615                                      warehouse_id => $self->warehouse->id,
616                                      bin_id       => $self->bin->id,
617                                      chargenumber => $::form->{chargenumber},
618                                      bestbefore   => $::form->{bestbefore},);
619   my $stocked_qty_in_form_units = $self->part->unit_obj->convert_to($stocked_qty, $self->unit);
620   my $qty        = $target_qty - $stocked_qty_in_form_units;
621   $qty           = abs($qty);
622
623   my $warn;
624   if ($qty > $::instance_conf->get_stocktaking_qty_threshold) {
625     $warn  = t8('The target quantity of #1 differs more than the threshold quantity of #2.',
626                 $::form->{target_qty} . " " . $self->unit->name,
627                 $::form->format_amount(\%::myconfig, $::instance_conf->get_stocktaking_qty_threshold, 2));
628     $warn .= "\n";
629     $warn .= t8('Choose "continue" if you want to use this value. Choose "cancel" otherwise.');
630   }
631   return $_[0]->render(\ $warn, { type => 'text' });
632 }
633
634 #================================================================
635
636 sub _check_auth {
637   $main::auth->assert('warehouse_management');
638 }
639
640 sub _check_warehouses {
641   $_[0]->show_no_warehouses_error if !@{ $_[0]->warehouses };
642 }
643
644 sub init_warehouses {
645   SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef ]]);
646 }
647
648 #sub init_bins {
649 #  SL::DB::Manager::Bin->get_all();
650 #}
651
652 sub init_units {
653   SL::DB::Manager::Unit->get_all;
654 }
655
656 sub init_is_stocktaking {
657   return $_[0]->action_name =~ m{stocktaking};
658 }
659
660 sub init_stocktaking_models {
661   my ($self) = @_;
662
663   SL::Controller::Helper::GetModels->new(
664     controller   => $self,
665     model        => 'Stocktaking',
666     sorted       => {
667       _default => {
668         by    => 'itime',
669         dir   => 0,
670       },
671       itime        => t8('Insert Date'),
672       qty          => t8('Target Qty'),
673       chargenumber => t8('Charge Number'),
674       comment      => t8('Comment'),
675       employee     => t8('Employee'),
676       ean          => t8('EAN'),
677       partnumber   => t8('Part Number'),
678       part         => t8('Part Description'),
679       bin          => t8('Bin'),
680       cutoff_date  => t8('Cutoff Date'),
681     },
682     with_objects => ['employee', 'parts', 'warehouse', 'bin'],
683   );
684 }
685
686 sub init_stocktaking_cutoff_date {
687   my ($self) = @_;
688
689   return DateTime->from_kivitendo($::form->{cutoff_date_as_date}) if $::form->{cutoff_date_as_date};
690   return SL::DB::Default->get->stocktaking_cutoff_date if SL::DB::Default->get->stocktaking_cutoff_date;
691
692   # Default cutoff date is last day of current year, but if current month
693   # is janurary, it is the last day of the last year.
694   my $now    = DateTime->now_local;
695   my $cutoff = DateTime->new(year => $now->year, month => 12, day => 31);
696   if ($now->month < 1) {
697     $cutoff->substract(years => 1);
698   }
699   return $cutoff;
700 }
701
702 sub set_target_from_part {
703   my ($self) = @_;
704
705   return if !$self->part;
706
707   $self->warehouse($self->part->warehouse) if $self->part->warehouse;
708   $self->bin(      $self->part->bin)       if $self->part->bin;
709 }
710
711 sub sanitize_target {
712   my ($self) = @_;
713
714   $self->warehouse($self->warehouses->[0])       if !$self->warehouse || !$self->warehouse->id;
715   $self->bin      ($self->warehouse->bins->[0])  if !$self->bin       || !$self->bin->id;
716 #  foreach my $warehouse ( $self->warehouses ) {
717 #      $warehouse->{BINS} = [];
718 #      foreach my $bin ( $self->bins ) {
719 #         if ( $bin->warehouse_id == $warehouse->id ) {
720 #             push @{ $warehouse->{BINS} }, $bin;
721 #         }
722 #      }
723 #  }
724 }
725
726 sub load_part_from_form {
727   $_[0]->part(SL::DB::Manager::Part->find_by_or_create(id => $::form->{part_id}));
728 }
729
730 sub load_unit_from_form {
731   $_[0]->unit(SL::DB::Manager::Unit->find_by_or_create(id => $::form->{unit_id}));
732 }
733
734 sub load_wh_from_form {
735   my $preselected;
736   $preselected = SL::DB::Default->get->stocktaking_warehouse_id if $_[0]->is_stocktaking;
737
738   $_[0]->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => ($::form->{warehouse_id} || $preselected)));
739 }
740
741 sub load_bin_from_form {
742   my $preselected;
743   $preselected = SL::DB::Default->get->stocktaking_bin_id if $_[0]->is_stocktaking;
744
745   $_[0]->bin(SL::DB::Manager::Bin->find_by_or_create(id => ($::form->{bin_id} || $preselected)));
746 }
747
748 sub set_layout {
749   $::request->layout->add_javascripts('client_js.js');
750 }
751
752 sub build_warehouse_select {
753   select_tag('warehouse_id', $_[0]->warehouses,
754    title_key => 'description',
755    default   => $_[0]->warehouse->id,
756    onchange  => 'reload_bin_selection()',
757   )
758 }
759
760 sub build_bin_select {
761   select_tag('bin_id', [ $_[0]->warehouse->bins ],
762     title_key => 'description',
763     default   => $_[0]->bin->id,
764   );
765 }
766
767 sub build_unit_select {
768   $_[0]->part->id
769     ? select_tag('unit_id', $_[0]->part->available_units,
770         title_key => 'name',
771         default   => $_[0]->part->unit_obj->id,
772       )
773     : select_tag('unit_id', $_[0]->units,
774         title_key => 'name',
775       )
776 }
777
778 sub mini_journal {
779   my ($self) = @_;
780
781   # get last 10 transaction ids
782   my $query = 'SELECT trans_id, max(itime) FROM inventory GROUP BY trans_id ORDER BY max(itime) DESC LIMIT 10';
783   my @ids = selectall_array_query($::form, $::form->get_standard_dbh, $query);
784
785   my $objs;
786   $objs = SL::DB::Manager::Inventory->get_all(query => [ trans_id => \@ids ]) if @ids;
787
788   # at most 2 of them belong to a transaction and the qty determins in or out.
789   # sort them for display
790   my %transactions;
791   for (@$objs) {
792     $transactions{ $_->trans_id }{ $_->qty > 0 ? 'in' : 'out' } = $_;
793     $transactions{ $_->trans_id }{base} = $_;
794   }
795   # and get them into order again
796   my @sorted = map { $transactions{$_} } @ids;
797
798   return \@sorted;
799 }
800
801 sub mini_stock {
802   my ($self) = @_;
803
804   my $stock             = $self->part->get_simple_stock;
805   $self->{stock_by_bin} = { map { $_->{bin_id} => $_ } @$stock };
806   $self->{stock_empty}  = ! grep { $_->{sum} * 1 } @$stock;
807 }
808
809 sub show_no_warehouses_error {
810   my ($self) = @_;
811
812   my $msg = t8('No warehouse has been created yet or the quantity of the bins is not configured yet.') . ' ';
813
814   if ($::auth->check_right($::myconfig{login}, 'config')) { # TODO wut?
815     $msg .= t8('You can create warehouses and bins via the menu "System -> Warehouses".');
816   } else {
817     $msg .= t8('Please ask your administrator to create warehouses and bins.');
818   }
819   $::form->show_generic_error($msg);
820 }
821
822 sub prepare_stocktaking_report {
823   my ($self, %params) = @_;
824
825   my $callback    = $self->stocktaking_models->get_callback;
826
827   my $report      = SL::ReportGenerator->new(\%::myconfig, $::form);
828   $self->{report} = $report;
829
830   my @columns     = qw(itime employee ean partnumber part qty unit bin chargenumber comment cutoff_date);
831   my @sortable    = qw(itime employee ean partnumber part qty bin chargenumber comment cutoff_date);
832
833   my %column_defs = (
834     itime           => { sub   => sub { $_[0]->itime_as_timestamp },
835                          text  => t8('Insert Date'), },
836     employee        => { sub   => sub { $_[0]->employee->safe_name },
837                          text  => t8('Employee'), },
838     ean             => { sub   => sub { $_[0]->part->ean },
839                          text  => t8('EAN'), },
840     partnumber      => { sub   => sub { $_[0]->part->partnumber },
841                          text  => t8('Part Number'), },
842     part            => { sub   => sub { $_[0]->part->description },
843                          text  => t8('Part Description'), },
844     qty             => { sub   => sub { $_[0]->qty_as_number },
845                          text  => t8('Target Qty'),
846                          align => 'right', },
847     unit            => { sub   => sub { $_[0]->part->unit },
848                          text  => t8('Unit'), },
849     bin             => { sub   => sub { $_[0]->bin->full_description },
850                          text  => t8('Bin'), },
851     chargenumber    => { text  => t8('Charge Number'), },
852     comment         => { text  => t8('Comment'), },
853     cutoff_date     => { sub   => sub { $_[0]->cutoff_date_as_date },
854                          text  => t8('Cutoff Date'), },
855   );
856
857   $report->set_options(
858     std_column_visibility => 1,
859     controller_class      => 'Inventory',
860     output_format         => 'HTML',
861     title                 => (!!$params{full})? $::locale->text('Stocktaking Journal') : $::locale->text('Stocktaking History'),
862     allow_pdf_export      => !!$params{full},
863     allow_csv_export      => !!$params{full},
864   );
865   $report->set_columns(%column_defs);
866   $report->set_column_order(@columns);
867   $report->set_export_options(qw(stocktaking_journal filter));
868   $report->set_options_from_form;
869   $self->stocktaking_models->disable_plugin('paginated') if $report->{options}{output_format} =~ /^(pdf|csv)$/i;
870   $self->stocktaking_models->set_report_generator_sort_options(report => $report, sortable_columns => \@sortable) if !!$params{full};
871   if (!!$params{full}) {
872     $report->set_options(
873       raw_top_info_text    => $self->render('inventory/stocktaking/full_report_top', { output => 0 }),
874     );
875   }
876   $report->set_options(
877     raw_bottom_info_text => $self->render('inventory/stocktaking/report_bottom',   { output => 0 }),
878   );
879 }
880
881 sub _get_stocked_qty {
882   my ($part, %params) = @_;
883
884   my $bestbefore_filter  = '';
885   my $bestbefore_val_cnt = 0;
886   if ($::instance_conf->get_show_bestbefore) {
887     $bestbefore_filter  = ($params{bestbefore}) ? 'AND bestbefore = ?' : 'AND bestbefore IS NULL';
888     $bestbefore_val_cnt = ($params{bestbefore}) ? 1                    : 0;
889   }
890
891   my $query = <<SQL;
892     SELECT sum(qty) FROM inventory
893       WHERE parts_id = ? AND warehouse_id = ? AND bin_id = ? AND chargenumber = ? $bestbefore_filter
894       GROUP BY warehouse_id, bin_id, chargenumber
895 SQL
896
897   my @values = ($part->id,
898                 $params{warehouse_id},
899                 $params{bin_id},
900                 $params{chargenumber});
901   push @values, $params{bestbefore} if $bestbefore_val_cnt;
902
903   my ($stocked_qty) = selectrow_query($::form, $::form->get_standard_dbh, $query, @values);
904
905   return 1*($stocked_qty || 0);
906 }
907
908 sub _already_counted {
909   my ($part, %params) = @_;
910
911   my %bestbefore_filter;
912   if ($::instance_conf->get_show_bestbefore) {
913     %bestbefore_filter = (bestbefore => $params{bestbefore});
914   }
915
916   SL::DB::Manager::Stocktaking->get_all(query => [and => [parts_id     => $part->id,
917                                                           warehouse_id => $params{warehouse_id},
918                                                           bin_id       => $params{bin_id},
919                                                           cutoff_date  => $params{cutoff_date},
920                                                           chargenumber => $params{chargenumber},
921                                                           %bestbefore_filter]],
922                                         sort_by => ['itime DESC']);
923 }
924
925 sub setup_stock_in_action_bar {
926   my ($self, %params) = @_;
927
928   for my $bar ($::request->layout->get('actionbar')) {
929     $bar->add(
930       action => [
931         t8('Stock'),
932         submit    => [ '#form', { action => 'Inventory/stock' } ],
933         checks    => [ 'check_part_selection_before_stocking' ],
934         accesskey => 'enter',
935       ],
936     );
937   }
938 }
939
940 sub setup_stock_usage_action_bar {
941   my ($self, %params) = @_;
942
943   for my $bar ($::request->layout->get('actionbar')) {
944     $bar->add(
945       action => [
946         t8('Show'),
947         submit    => [ '#form', { action => 'Inventory/usage' } ],
948         accesskey => 'enter',
949       ],
950     );
951   }
952 }
953
954 sub setup_stock_stocktaking_action_bar {
955   my ($self, %params) = @_;
956
957   for my $bar ($::request->layout->get('actionbar')) {
958     $bar->add(
959       action => [
960         t8('Save'),
961         checks    => [ 'kivi.Inventory.check_stocktaking_qty_threshold' ],
962         call      => [ 'kivi.Inventory.save_stocktaking' ],
963         accesskey => 'enter',
964       ],
965     );
966   }
967 }
968
969 1;
970 __END__
971
972 =encoding utf-8
973
974 =head1 NAME
975
976 SL::Controller::Inventory - Controller for inventory
977
978 =head1 DESCRIPTION
979
980 This controller handles stock in, stocktaking and reports about inventory
981 in warehouses/stocks
982
983 - warehouse content
984
985 - warehouse journal
986
987 - warehouse withdrawal
988
989 - stocktaking
990
991 =head2 Stocktaking
992
993 Stocktaking allows to document the counted quantities of parts during
994 stocktaking for a certain cutoff date. Differences between counted and stocked
995 quantities are corrected in the stock. The transfer type 'stocktacking' is set
996 here.
997
998 After picking a part, the mini stock for this part is displayed. At the bottom
999 of the form a history of already counted parts for the current employee and the
1000 choosen cutoff date is shown.
1001
1002 Warehouse, bin and cutoff date canbe preselected in the client configuration.
1003
1004 If a part was already counted for this cutoff date, warehouse and bin, a warning
1005 is displayed, allowing the user to choose to add the counted quantity to the
1006 stocked one or to take his counted quantity as the new stocked quantity.
1007
1008 There is also a journal of stocktakings.
1009
1010 Templates are located under C<templates/webpages/inventory/stocktaking>.
1011 JavaScript functions can be found in C<js/kivi.Inventory.js>.
1012
1013 =head1 FUNCTIONS
1014
1015 =over 4
1016
1017 =item C<action_stock_usage>
1018
1019 Create a search form for stock withdrawal.
1020 The search parameter for report are made like the reports in bin/mozilla/rp.pl
1021
1022 =item C<action_usage>
1023
1024 Make a report about stock withdrawal.
1025
1026 The manual pagination is implemented like the pagination in SL::Controller::CsvImport.
1027
1028 =item C<action_stocktaking>
1029
1030 This action renders the input form for stocktaking.
1031
1032 =item C<action_save_stocktaking>
1033
1034 This action saves the stocktaking values and corrects the stock after checking
1035 if the part is already counted for this warehouse, bin and cutoff date.
1036 For saving SL::WH->transfer is called.
1037
1038 =item C<action_reload_stocktaking_history>
1039
1040 This action is responsible for displaying the stocktaking history at the bottom
1041 of the form. It uses the stocktaking journal with fixed filters for cutoff date
1042 and the current employee. The history is displayed via javascript.
1043
1044 =item C<action_stocktaking_part_changed>
1045
1046 This action is called after the user selected or changed the part.
1047
1048 =item C<action_stocktaking_get_warn_qty_threshold>
1049
1050 This action checks if a warning should be shown and returns the warning text via
1051 ajax. The warning will be shown if the given target value is greater than the
1052 threshold given in the client configuration.
1053
1054 =item C<is_stocktaking>
1055
1056 This is a method to check if actions are called from stocktaking form.
1057 This actions should contain "stocktaking" in their name.
1058
1059 =back
1060
1061 =head1 SPECIAL CASES
1062
1063 Because of the PFD-Table Formatter some parameters for PDF must be different to the HTML parameters.
1064 So in german language there are some tries to use a HTML Break in the second heading line
1065 to produce two line heading inside table. The actual version has some abbreviations for the header texts.
1066
1067 =head1 BUGS
1068
1069 The PDF-Table library has some limits (doesn't display all if the line is to large) so
1070 the format is adapted to this
1071
1072
1073 =head1 AUTHOR
1074
1075 =over 4
1076
1077 =item only for C<action_stock_usage> and C<action_usage>:
1078
1079 Martin Helmling E<lt>martin.helmling@opendynamic.deE<gt>
1080
1081 =item for stocktaking:
1082
1083 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>
1084
1085 =back
1086
1087 =cut