Lager/Einlagern: Grund der Einlagerung wird ignoriert
[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           transfer_type_id => $::form->{transfer_type_id},
415           chargenumber  => $::form->{chargenumber},
416           bestbefore    => $::form->{bestbefore},
417           ean           => $::form->{ean},
418           comment       => $::form->{comment},
419         });
420         1;
421       } or do { $transfer_error = $EVAL_ERROR->getMessage; }
422     });
423
424     if (!$transfer_error) {
425       if ($::form->{write_default_bin}) {
426         $self->part->load;   # onhand is calculated in between. don't mess that up
427         $self->part->bin($self->bin);
428         $self->part->warehouse($self->warehouse);
429         $self->part->save;
430       }
431
432       flash_later('info', t8('Transfer successful'));
433     }
434   }
435
436   my %additional_redirect_params = ();
437   if ($transfer_error) {
438     flash_later('error', $transfer_error);
439     $additional_redirect_params{$_}  = $::form->{$_} for qw(qty chargenumber bestbefore ean comment);
440     $additional_redirect_params{qty} = $qty;
441   }
442
443   # redirect
444   $self->redirect_to(
445     action       => 'stock_in',
446     part_id      => $self->part->id,
447     bin_id       => $self->bin->id,
448     warehouse_id => $self->warehouse->id,
449     unit_id      => $self->unit->id,
450     %additional_redirect_params,
451   );
452 }
453
454 sub action_part_changed {
455   my ($self) = @_;
456
457   # no standard? ask user if he wants to write it
458   if ($self->part->id && !$self->part->bin_id && !$self->part->warehouse_id) {
459     $self->js->show('#write_default_bin_span');
460   } else {
461     $self->js->hide('#write_default_bin_span')
462              ->removeAttr('#write_default_bin', 'checked');
463   }
464
465   $self->js
466     ->replaceWith('#warehouse_id', $self->build_warehouse_select)
467     ->replaceWith('#bin_id', $self->build_bin_select)
468     ->replaceWith('#unit_id', $self->build_unit_select)
469     ->focus('#warehouse_id')
470     ->render;
471 }
472
473 sub action_warehouse_changed {
474   my ($self) = @_;
475
476   $self->js
477     ->replaceWith('#bin_id', $self->build_bin_select)
478     ->focus('#bin_id')
479     ->render;
480 }
481
482 sub action_mini_stock {
483   my ($self) = @_;
484
485   $self->js
486     ->html('#stock', $self->render('inventory/_stock', { output => 0 }))
487     ->render;
488 }
489
490 sub action_stocktaking {
491   my ($self) = @_;
492
493   $::request->{layout}->use_javascript("${_}.js") for qw(kivi.Inventory);
494   $::request->layout->focus('#part_id_name');
495   $self->setup_stock_stocktaking_action_bar;
496   $self->render('inventory/stocktaking/form', title => t8('Stocktaking'));
497 }
498
499 sub action_save_stocktaking {
500   my ($self) = @_;
501
502   return $self->js->flash('error', t8('Please choose a part.'))->render()
503     if !$::form->{part_id};
504
505   return $self->js->flash('error', t8('A target quantitiy has to be given'))->render()
506     if $::form->{target_qty} eq '';
507
508   my $target_qty = $::form->parse_amount(\%::myconfig, $::form->{target_qty});
509
510   return $self->js->flash('error', t8('Error: A negative target quantity is not allowed.'))->render()
511     if $target_qty < 0;
512
513   my $stocked_qty  = _get_stocked_qty($self->part,
514                                       warehouse_id => $self->warehouse->id,
515                                       bin_id       => $self->bin->id,
516                                       chargenumber => $::form->{chargenumber},
517                                       bestbefore   => $::form->{bestbefore},);
518
519   my $stocked_qty_in_form_units = $self->part->unit_obj->convert_to($stocked_qty, $self->unit);
520
521   if (!$::form->{dont_check_already_counted}) {
522     my $already_counted = _already_counted($self->part,
523                                            warehouse_id => $self->warehouse->id,
524                                            bin_id       => $self->bin->id,
525                                            cutoff_date  => $::form->{cutoff_date_as_date},
526                                            chargenumber => $::form->{chargenumber},
527                                            bestbefore   => $::form->{bestbefore});
528     if (scalar @$already_counted) {
529       my $reply = $self->js->dialog->open({
530         html   => $self->render('inventory/stocktaking/_already_counted_dialog',
531                                 { output => 0 },
532                                 already_counted           => $already_counted,
533                                 stocked_qty               => $stocked_qty,
534                                 stocked_qty_in_form_units => $stocked_qty_in_form_units),
535         id     => 'already_counted_dialog',
536         dialog => {
537           title => t8('Already counted'),
538         },
539       })->render;
540
541       return $reply;
542     }
543   }
544
545   # - target_qty is in units given in form ($self->unit)
546   # - WH->transfer expects qtys in given unit (here: unit from form (unit -> $self->unit))
547   # Therefore use stocked_qty in form units for calculation.
548   my $qty        = $target_qty - $stocked_qty_in_form_units;
549   my $src_or_dst = $qty < 0? 'src' : 'dst';
550   $qty           = abs($qty);
551
552   my $transfer_error;
553   # do stock
554   $::form->throw_on_error(sub {
555     eval {
556       WH->transfer({
557         parts                   => $self->part,
558         $src_or_dst.'_bin'      => $self->bin,
559         $src_or_dst.'_wh'       => $self->warehouse,
560         qty                     => $qty,
561         unit                    => $self->unit,
562         transfer_type           => 'stocktaking',
563         chargenumber            => $::form->{chargenumber},
564         bestbefore              => $::form->{bestbefore},
565         ean                     => $::form->{ean},
566         comment                 => $::form->{comment},
567         record_stocktaking      => 1,
568         stocktaking_qty         => $target_qty,
569         stocktaking_cutoff_date => $::form->{cutoff_date_as_date},
570       });
571       1;
572     } or do { $transfer_error = $EVAL_ERROR->getMessage; }
573   });
574
575   return $self->js->flash('error', $transfer_error)->render()
576     if $transfer_error;
577
578   flash_later('info', $::locale->text('Part successful counted'));
579   $self->redirect_to(action              => 'stocktaking',
580                      warehouse_id        => $self->warehouse->id,
581                      bin_id              => $self->bin->id,
582                      cutoff_date_as_date => $self->stocktaking_cutoff_date->to_kivitendo);
583 }
584
585 sub action_reload_stocktaking_history {
586   my ($self) = @_;
587
588   $::form->{filter}{'cutoff_date:date'} = $self->stocktaking_cutoff_date->to_kivitendo;
589   $::form->{filter}{'employee_id'}      = SL::DB::Manager::Employee->current->id;
590
591   $self->prepare_stocktaking_report;
592   $self->report_generator_list_objects(report => $self->{report}, objects => $self->stocktaking_models->get, layout => 0, header => 0);
593 }
594
595 sub action_stocktaking_part_changed {
596   my ($self) = @_;
597
598   $self->js
599     ->replaceWith('#unit_id', $self->build_unit_select)
600     ->focus('#target_qty')
601     ->render;
602 }
603
604 sub action_stocktaking_journal {
605   my ($self) = @_;
606
607   $self->prepare_stocktaking_report(full => 1);
608   $self->report_generator_list_objects(report => $self->{report}, objects => $self->stocktaking_models->get);
609 }
610
611 sub action_stocktaking_get_warn_qty_threshold {
612   my ($self) = @_;
613
614   return $_[0]->render(\ !!0, { type => 'text' }) if !$::form->{part_id};
615   return $_[0]->render(\ !!0, { type => 'text' }) if $::form->{target_qty} eq '';
616   return $_[0]->render(\ !!0, { type => 'text' }) if 0 == $::instance_conf->get_stocktaking_qty_threshold;
617
618   my $target_qty  = $::form->parse_amount(\%::myconfig, $::form->{target_qty});
619   my $stocked_qty = _get_stocked_qty($self->part,
620                                      warehouse_id => $self->warehouse->id,
621                                      bin_id       => $self->bin->id,
622                                      chargenumber => $::form->{chargenumber},
623                                      bestbefore   => $::form->{bestbefore},);
624   my $stocked_qty_in_form_units = $self->part->unit_obj->convert_to($stocked_qty, $self->unit);
625   my $qty        = $target_qty - $stocked_qty_in_form_units;
626   $qty           = abs($qty);
627
628   my $warn;
629   if ($qty > $::instance_conf->get_stocktaking_qty_threshold) {
630     $warn  = t8('The target quantity of #1 differs more than the threshold quantity of #2.',
631                 $::form->{target_qty} . " " . $self->unit->name,
632                 $::form->format_amount(\%::myconfig, $::instance_conf->get_stocktaking_qty_threshold, 2));
633     $warn .= "\n";
634     $warn .= t8('Choose "continue" if you want to use this value. Choose "cancel" otherwise.');
635   }
636   return $_[0]->render(\ $warn, { type => 'text' });
637 }
638
639 #================================================================
640
641 sub _check_auth {
642   $main::auth->assert('warehouse_management');
643 }
644
645 sub _check_warehouses {
646   $_[0]->show_no_warehouses_error if !@{ $_[0]->warehouses };
647 }
648
649 sub init_warehouses {
650   SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef ]]);
651 }
652
653 #sub init_bins {
654 #  SL::DB::Manager::Bin->get_all();
655 #}
656
657 sub init_units {
658   SL::DB::Manager::Unit->get_all;
659 }
660
661 sub init_is_stocktaking {
662   return $_[0]->action_name =~ m{stocktaking};
663 }
664
665 sub init_stocktaking_models {
666   my ($self) = @_;
667
668   SL::Controller::Helper::GetModels->new(
669     controller   => $self,
670     model        => 'Stocktaking',
671     sorted       => {
672       _default => {
673         by    => 'itime',
674         dir   => 0,
675       },
676       itime        => t8('Insert Date'),
677       qty          => t8('Target Qty'),
678       chargenumber => t8('Charge Number'),
679       comment      => t8('Comment'),
680       employee     => t8('Employee'),
681       ean          => t8('EAN'),
682       partnumber   => t8('Part Number'),
683       part         => t8('Part Description'),
684       bin          => t8('Bin'),
685       cutoff_date  => t8('Cutoff Date'),
686     },
687     with_objects => ['employee', 'parts', 'warehouse', 'bin'],
688   );
689 }
690
691 sub init_stocktaking_cutoff_date {
692   my ($self) = @_;
693
694   return DateTime->from_kivitendo($::form->{cutoff_date_as_date}) if $::form->{cutoff_date_as_date};
695   return SL::DB::Default->get->stocktaking_cutoff_date if SL::DB::Default->get->stocktaking_cutoff_date;
696
697   # Default cutoff date is last day of current year, but if current month
698   # is janurary, it is the last day of the last year.
699   my $now    = DateTime->now_local;
700   my $cutoff = DateTime->new(year => $now->year, month => 12, day => 31);
701   if ($now->month < 1) {
702     $cutoff->substract(years => 1);
703   }
704   return $cutoff;
705 }
706
707 sub set_target_from_part {
708   my ($self) = @_;
709
710   return if !$self->part;
711
712   $self->warehouse($self->part->warehouse) if $self->part->warehouse;
713   $self->bin(      $self->part->bin)       if $self->part->bin;
714 }
715
716 sub sanitize_target {
717   my ($self) = @_;
718
719   $self->warehouse($self->warehouses->[0])       if !$self->warehouse || !$self->warehouse->id;
720   $self->bin      ($self->warehouse->bins->[0])  if !$self->bin       || !$self->bin->id;
721 #  foreach my $warehouse ( $self->warehouses ) {
722 #      $warehouse->{BINS} = [];
723 #      foreach my $bin ( $self->bins ) {
724 #         if ( $bin->warehouse_id == $warehouse->id ) {
725 #             push @{ $warehouse->{BINS} }, $bin;
726 #         }
727 #      }
728 #  }
729 }
730
731 sub load_part_from_form {
732   $_[0]->part(SL::DB::Manager::Part->find_by_or_create(id => $::form->{part_id}||undef));
733 }
734
735 sub load_unit_from_form {
736   $_[0]->unit(SL::DB::Manager::Unit->find_by_or_create(id => $::form->{unit_id}));
737 }
738
739 sub load_wh_from_form {
740   my $preselected;
741   $preselected = SL::DB::Default->get->stocktaking_warehouse_id if $_[0]->is_stocktaking;
742
743   $_[0]->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => ($::form->{warehouse_id} || $preselected)));
744 }
745
746 sub load_bin_from_form {
747   my $preselected;
748   $preselected = SL::DB::Default->get->stocktaking_bin_id if $_[0]->is_stocktaking;
749
750   $_[0]->bin(SL::DB::Manager::Bin->find_by_or_create(id => ($::form->{bin_id} || $preselected)));
751 }
752
753 sub set_layout {
754   $::request->layout->add_javascripts('client_js.js');
755 }
756
757 sub build_warehouse_select {
758   select_tag('warehouse_id', $_[0]->warehouses,
759    title_key => 'description',
760    default   => $_[0]->warehouse->id,
761    onchange  => 'reload_bin_selection()',
762   )
763 }
764
765 sub build_bin_select {
766   select_tag('bin_id', [ $_[0]->warehouse->bins ],
767     title_key => 'description',
768     default   => $_[0]->bin->id,
769   );
770 }
771
772 sub build_unit_select {
773   $_[0]->part->id
774     ? select_tag('unit_id', $_[0]->part->available_units,
775         title_key => 'name',
776         default   => $_[0]->part->unit_obj->id,
777       )
778     : select_tag('unit_id', $_[0]->units,
779         title_key => 'name',
780       )
781 }
782
783 sub mini_journal {
784   my ($self) = @_;
785
786   # get last 10 transaction ids
787   my $query = 'SELECT trans_id, max(itime) FROM inventory GROUP BY trans_id ORDER BY max(itime) DESC LIMIT 10';
788   my @ids = selectall_array_query($::form, $::form->get_standard_dbh, $query);
789
790   my $objs;
791   $objs = SL::DB::Manager::Inventory->get_all(query => [ trans_id => \@ids ]) if @ids;
792
793   # at most 2 of them belong to a transaction and the qty determins in or out.
794   # sort them for display
795   my %transactions;
796   for (@$objs) {
797     $transactions{ $_->trans_id }{ $_->qty > 0 ? 'in' : 'out' } = $_;
798     $transactions{ $_->trans_id }{base} = $_;
799   }
800   # and get them into order again
801   my @sorted = map { $transactions{$_} } @ids;
802
803   return \@sorted;
804 }
805
806 sub mini_stock {
807   my ($self) = @_;
808
809   my $stock             = $self->part->get_simple_stock;
810   $self->{stock_by_bin} = { map { $_->{bin_id} => $_ } @$stock };
811   $self->{stock_empty}  = ! grep { $_->{sum} * 1 } @$stock;
812 }
813
814 sub show_no_warehouses_error {
815   my ($self) = @_;
816
817   my $msg = t8('No warehouse has been created yet or the quantity of the bins is not configured yet.') . ' ';
818
819   if ($::auth->check_right($::myconfig{login}, 'config')) { # TODO wut?
820     $msg .= t8('You can create warehouses and bins via the menu "System -> Warehouses".');
821   } else {
822     $msg .= t8('Please ask your administrator to create warehouses and bins.');
823   }
824   $::form->show_generic_error($msg);
825 }
826
827 sub prepare_stocktaking_report {
828   my ($self, %params) = @_;
829
830   my $callback    = $self->stocktaking_models->get_callback;
831
832   my $report      = SL::ReportGenerator->new(\%::myconfig, $::form);
833   $self->{report} = $report;
834
835   my @columns     = qw(itime employee ean partnumber part qty unit bin chargenumber comment cutoff_date);
836   my @sortable    = qw(itime employee ean partnumber part qty bin chargenumber comment cutoff_date);
837
838   my %column_defs = (
839     itime           => { sub   => sub { $_[0]->itime_as_timestamp },
840                          text  => t8('Insert Date'), },
841     employee        => { sub   => sub { $_[0]->employee->safe_name },
842                          text  => t8('Employee'), },
843     ean             => { sub   => sub { $_[0]->part->ean },
844                          text  => t8('EAN'), },
845     partnumber      => { sub   => sub { $_[0]->part->partnumber },
846                          text  => t8('Part Number'), },
847     part            => { sub   => sub { $_[0]->part->description },
848                          text  => t8('Part Description'), },
849     qty             => { sub   => sub { $_[0]->qty_as_number },
850                          text  => t8('Target Qty'),
851                          align => 'right', },
852     unit            => { sub   => sub { $_[0]->part->unit },
853                          text  => t8('Unit'), },
854     bin             => { sub   => sub { $_[0]->bin->full_description },
855                          text  => t8('Bin'), },
856     chargenumber    => { text  => t8('Charge Number'), },
857     comment         => { text  => t8('Comment'), },
858     cutoff_date     => { sub   => sub { $_[0]->cutoff_date_as_date },
859                          text  => t8('Cutoff Date'), },
860   );
861
862   $report->set_options(
863     std_column_visibility => 1,
864     controller_class      => 'Inventory',
865     output_format         => 'HTML',
866     title                 => (!!$params{full})? $::locale->text('Stocktaking Journal') : $::locale->text('Stocktaking History'),
867     allow_pdf_export      => !!$params{full},
868     allow_csv_export      => !!$params{full},
869   );
870   $report->set_columns(%column_defs);
871   $report->set_column_order(@columns);
872   $report->set_export_options(qw(stocktaking_journal filter));
873   $report->set_options_from_form;
874   $self->stocktaking_models->disable_plugin('paginated') if $report->{options}{output_format} =~ /^(pdf|csv)$/i;
875   $self->stocktaking_models->set_report_generator_sort_options(report => $report, sortable_columns => \@sortable) if !!$params{full};
876   if (!!$params{full}) {
877     $report->set_options(
878       raw_top_info_text    => $self->render('inventory/stocktaking/full_report_top', { output => 0 }),
879     );
880   }
881   $report->set_options(
882     raw_bottom_info_text => $self->render('inventory/stocktaking/report_bottom',   { output => 0 }),
883   );
884 }
885
886 sub _get_stocked_qty {
887   my ($part, %params) = @_;
888
889   my $bestbefore_filter  = '';
890   my $bestbefore_val_cnt = 0;
891   if ($::instance_conf->get_show_bestbefore) {
892     $bestbefore_filter  = ($params{bestbefore}) ? 'AND bestbefore = ?' : 'AND bestbefore IS NULL';
893     $bestbefore_val_cnt = ($params{bestbefore}) ? 1                    : 0;
894   }
895
896   my $query = <<SQL;
897     SELECT sum(qty) FROM inventory
898       WHERE parts_id = ? AND warehouse_id = ? AND bin_id = ? AND chargenumber = ? $bestbefore_filter
899       GROUP BY warehouse_id, bin_id, chargenumber
900 SQL
901
902   my @values = ($part->id,
903                 $params{warehouse_id},
904                 $params{bin_id},
905                 $params{chargenumber});
906   push @values, $params{bestbefore} if $bestbefore_val_cnt;
907
908   my ($stocked_qty) = selectrow_query($::form, $::form->get_standard_dbh, $query, @values);
909
910   return 1*($stocked_qty || 0);
911 }
912
913 sub _already_counted {
914   my ($part, %params) = @_;
915
916   my %bestbefore_filter;
917   if ($::instance_conf->get_show_bestbefore) {
918     %bestbefore_filter = (bestbefore => $params{bestbefore});
919   }
920
921   SL::DB::Manager::Stocktaking->get_all(query => [and => [parts_id     => $part->id,
922                                                           warehouse_id => $params{warehouse_id},
923                                                           bin_id       => $params{bin_id},
924                                                           cutoff_date  => $params{cutoff_date},
925                                                           chargenumber => $params{chargenumber},
926                                                           %bestbefore_filter]],
927                                         sort_by => ['itime DESC']);
928 }
929
930 sub setup_stock_in_action_bar {
931   my ($self, %params) = @_;
932
933   for my $bar ($::request->layout->get('actionbar')) {
934     $bar->add(
935       action => [
936         t8('Stock'),
937         submit    => [ '#form', { action => 'Inventory/stock' } ],
938         checks    => [ 'check_part_selection_before_stocking' ],
939         accesskey => 'enter',
940       ],
941     );
942   }
943 }
944
945 sub setup_stock_usage_action_bar {
946   my ($self, %params) = @_;
947
948   for my $bar ($::request->layout->get('actionbar')) {
949     $bar->add(
950       action => [
951         t8('Show'),
952         submit    => [ '#form', { action => 'Inventory/usage' } ],
953         accesskey => 'enter',
954       ],
955     );
956   }
957 }
958
959 sub setup_stock_stocktaking_action_bar {
960   my ($self, %params) = @_;
961
962   for my $bar ($::request->layout->get('actionbar')) {
963     $bar->add(
964       action => [
965         t8('Save'),
966         checks    => [ 'kivi.Inventory.check_stocktaking_qty_threshold' ],
967         call      => [ 'kivi.Inventory.save_stocktaking' ],
968         accesskey => 'enter',
969       ],
970     );
971   }
972 }
973
974 1;
975 __END__
976
977 =encoding utf-8
978
979 =head1 NAME
980
981 SL::Controller::Inventory - Controller for inventory
982
983 =head1 DESCRIPTION
984
985 This controller handles stock in, stocktaking and reports about inventory
986 in warehouses/stocks
987
988 - warehouse content
989
990 - warehouse journal
991
992 - warehouse withdrawal
993
994 - stocktaking
995
996 =head2 Stocktaking
997
998 Stocktaking allows to document the counted quantities of parts during
999 stocktaking for a certain cutoff date. Differences between counted and stocked
1000 quantities are corrected in the stock. The transfer type 'stocktacking' is set
1001 here.
1002
1003 After picking a part, the mini stock for this part is displayed. At the bottom
1004 of the form a history of already counted parts for the current employee and the
1005 choosen cutoff date is shown.
1006
1007 Warehouse, bin and cutoff date canbe preselected in the client configuration.
1008
1009 If a part was already counted for this cutoff date, warehouse and bin, a warning
1010 is displayed, allowing the user to choose to add the counted quantity to the
1011 stocked one or to take his counted quantity as the new stocked quantity.
1012
1013 There is also a journal of stocktakings.
1014
1015 Templates are located under C<templates/webpages/inventory/stocktaking>.
1016 JavaScript functions can be found in C<js/kivi.Inventory.js>.
1017
1018 =head1 FUNCTIONS
1019
1020 =over 4
1021
1022 =item C<action_stock_usage>
1023
1024 Create a search form for stock withdrawal.
1025 The search parameter for report are made like the reports in bin/mozilla/rp.pl
1026
1027 =item C<action_usage>
1028
1029 Make a report about stock withdrawal.
1030
1031 The manual pagination is implemented like the pagination in SL::Controller::CsvImport.
1032
1033 =item C<action_stocktaking>
1034
1035 This action renders the input form for stocktaking.
1036
1037 =item C<action_save_stocktaking>
1038
1039 This action saves the stocktaking values and corrects the stock after checking
1040 if the part is already counted for this warehouse, bin and cutoff date.
1041 For saving SL::WH->transfer is called.
1042
1043 =item C<action_reload_stocktaking_history>
1044
1045 This action is responsible for displaying the stocktaking history at the bottom
1046 of the form. It uses the stocktaking journal with fixed filters for cutoff date
1047 and the current employee. The history is displayed via javascript.
1048
1049 =item C<action_stocktaking_part_changed>
1050
1051 This action is called after the user selected or changed the part.
1052
1053 =item C<action_stocktaking_get_warn_qty_threshold>
1054
1055 This action checks if a warning should be shown and returns the warning text via
1056 ajax. The warning will be shown if the given target value is greater than the
1057 threshold given in the client configuration.
1058
1059 =item C<is_stocktaking>
1060
1061 This is a method to check if actions are called from stocktaking form.
1062 This actions should contain "stocktaking" in their name.
1063
1064 =back
1065
1066 =head1 SPECIAL CASES
1067
1068 Because of the PFD-Table Formatter some parameters for PDF must be different to the HTML parameters.
1069 So in german language there are some tries to use a HTML Break in the second heading line
1070 to produce two line heading inside table. The actual version has some abbreviations for the header texts.
1071
1072 =head1 BUGS
1073
1074 The PDF-Table library has some limits (doesn't display all if the line is to large) so
1075 the format is adapted to this
1076
1077
1078 =head1 AUTHOR
1079
1080 =over 4
1081
1082 =item only for C<action_stock_usage> and C<action_usage>:
1083
1084 Martin Helmling E<lt>martin.helmling@opendynamic.deE<gt>
1085
1086 =item for stocktaking:
1087
1088 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>
1089
1090 =back
1091
1092 =cut