Inventur: Eingabemaske und Journal im Inventory-Controller
[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 save_stocktaking) ]);
34 __PACKAGE__->run_before('load_unit_from_form',   only => [ qw(stock_in part_changed mini_stock stock stocktaking_part_changed save_stocktaking) ]);
35 __PACKAGE__->run_before('load_wh_from_form',     only => [ qw(stock_in warehouse_changed stock stocktaking save_stocktaking) ]);
36 __PACKAGE__->run_before('load_bin_from_form',    only => [ qw(stock_in stock stocktaking 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 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
608 sub _check_auth {
609   $main::auth->assert('warehouse_management');
610 }
611
612 sub _check_warehouses {
613   $_[0]->show_no_warehouses_error if !@{ $_[0]->warehouses };
614 }
615
616 sub init_warehouses {
617   SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef ]]);
618 }
619
620 #sub init_bins {
621 #  SL::DB::Manager::Bin->get_all();
622 #}
623
624 sub init_units {
625   SL::DB::Manager::Unit->get_all;
626 }
627
628 sub init_is_stocktaking {
629   return $_[0]->action_name =~ m{stocktaking};
630 }
631
632 sub init_stocktaking_models {
633   my ($self) = @_;
634
635   SL::Controller::Helper::GetModels->new(
636     controller   => $self,
637     model        => 'Stocktaking',
638     sorted       => {
639       _default => {
640         by    => 'itime',
641         dir   => 0,
642       },
643       itime        => t8('Insert Date'),
644       qty          => t8('Target Qty'),
645       chargenumber => t8('Charge Number'),
646       comment      => t8('Comment'),
647       employee     => t8('Employee'),
648       ean          => t8('EAN'),
649       partnumber   => t8('Part Number'),
650       part         => t8('Part Description'),
651       bin          => t8('Bin'),
652       cutoff_date  => t8('Cutoff Date'),
653     },
654     with_objects => ['employee', 'parts', 'warehouse', 'bin'],
655   );
656 }
657
658 sub init_stocktaking_cutoff_date {
659   my ($self) = @_;
660
661   return DateTime->from_kivitendo($::form->{cutoff_date_as_date}) if $::form->{cutoff_date_as_date};
662   return SL::DB::Default->get->stocktaking_cutoff_date if SL::DB::Default->get->stocktaking_cutoff_date;
663
664   # Default cutoff date is last day of current year, but if current month
665   # is janurary, it is the last day of the last year.
666   my $now    = DateTime->now_local;
667   my $cutoff = DateTime->new(year => $now->year, month => 12, day => 31);
668   if ($now->month < 1) {
669     $cutoff->substract(years => 1);
670   }
671   return $cutoff;
672 }
673
674 sub set_target_from_part {
675   my ($self) = @_;
676
677   return if !$self->part;
678
679   $self->warehouse($self->part->warehouse) if $self->part->warehouse;
680   $self->bin(      $self->part->bin)       if $self->part->bin;
681 }
682
683 sub sanitize_target {
684   my ($self) = @_;
685
686   $self->warehouse($self->warehouses->[0])       if !$self->warehouse || !$self->warehouse->id;
687   $self->bin      ($self->warehouse->bins->[0])  if !$self->bin       || !$self->bin->id;
688 #  foreach my $warehouse ( $self->warehouses ) {
689 #      $warehouse->{BINS} = [];
690 #      foreach my $bin ( $self->bins ) {
691 #         if ( $bin->warehouse_id == $warehouse->id ) {
692 #             push @{ $warehouse->{BINS} }, $bin;
693 #         }
694 #      }
695 #  }
696 }
697
698 sub load_part_from_form {
699   $_[0]->part(SL::DB::Manager::Part->find_by_or_create(id => $::form->{part_id}));
700 }
701
702 sub load_unit_from_form {
703   $_[0]->unit(SL::DB::Manager::Unit->find_by_or_create(id => $::form->{unit_id}));
704 }
705
706 sub load_wh_from_form {
707   my $preselected;
708   $preselected = SL::DB::Default->get->stocktaking_warehouse_id if $_[0]->is_stocktaking;
709
710   $_[0]->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => ($::form->{warehouse_id} || $preselected)));
711 }
712
713 sub load_bin_from_form {
714   my $preselected;
715   $preselected = SL::DB::Default->get->stocktaking_bin_id if $_[0]->is_stocktaking;
716
717   $_[0]->bin(SL::DB::Manager::Bin->find_by_or_create(id => ($::form->{bin_id} || $preselected)));
718 }
719
720 sub set_layout {
721   $::request->layout->add_javascripts('client_js.js');
722 }
723
724 sub build_warehouse_select {
725   select_tag('warehouse_id', $_[0]->warehouses,
726    title_key => 'description',
727    default   => $_[0]->warehouse->id,
728    onchange  => 'reload_bin_selection()',
729   )
730 }
731
732 sub build_bin_select {
733   select_tag('bin_id', [ $_[0]->warehouse->bins ],
734     title_key => 'description',
735     default   => $_[0]->bin->id,
736   );
737 }
738
739 sub build_unit_select {
740   $_[0]->part->id
741     ? select_tag('unit_id', $_[0]->part->available_units,
742         title_key => 'name',
743         default   => $_[0]->part->unit_obj->id,
744       )
745     : select_tag('unit_id', $_[0]->units,
746         title_key => 'name',
747       )
748 }
749
750 sub mini_journal {
751   my ($self) = @_;
752
753   # get last 10 transaction ids
754   my $query = 'SELECT trans_id, max(itime) FROM inventory GROUP BY trans_id ORDER BY max(itime) DESC LIMIT 10';
755   my @ids = selectall_array_query($::form, $::form->get_standard_dbh, $query);
756
757   my $objs;
758   $objs = SL::DB::Manager::Inventory->get_all(query => [ trans_id => \@ids ]) if @ids;
759
760   # at most 2 of them belong to a transaction and the qty determins in or out.
761   # sort them for display
762   my %transactions;
763   for (@$objs) {
764     $transactions{ $_->trans_id }{ $_->qty > 0 ? 'in' : 'out' } = $_;
765     $transactions{ $_->trans_id }{base} = $_;
766   }
767   # and get them into order again
768   my @sorted = map { $transactions{$_} } @ids;
769
770   return \@sorted;
771 }
772
773 sub mini_stock {
774   my ($self) = @_;
775
776   my $stock             = $self->part->get_simple_stock;
777   $self->{stock_by_bin} = { map { $_->{bin_id} => $_ } @$stock };
778   $self->{stock_empty}  = ! grep { $_->{sum} * 1 } @$stock;
779 }
780
781 sub show_no_warehouses_error {
782   my ($self) = @_;
783
784   my $msg = t8('No warehouse has been created yet or the quantity of the bins is not configured yet.') . ' ';
785
786   if ($::auth->check_right($::myconfig{login}, 'config')) { # TODO wut?
787     $msg .= t8('You can create warehouses and bins via the menu "System -> Warehouses".');
788   } else {
789     $msg .= t8('Please ask your administrator to create warehouses and bins.');
790   }
791   $::form->show_generic_error($msg);
792 }
793
794 sub prepare_stocktaking_report {
795   my ($self, %params) = @_;
796
797   my $callback    = $self->stocktaking_models->get_callback;
798
799   my $report      = SL::ReportGenerator->new(\%::myconfig, $::form);
800   $self->{report} = $report;
801
802   my @columns     = qw(itime employee ean partnumber part qty unit bin chargenumber comment cutoff_date);
803   my @sortable    = qw(itime employee ean partnumber part qty bin chargenumber comment cutoff_date);
804
805   my %column_defs = (
806     itime           => { sub   => sub { $_[0]->itime_as_timestamp },
807                          text  => t8('Insert Date'), },
808     employee        => { sub   => sub { $_[0]->employee->safe_name },
809                          text  => t8('Employee'), },
810     ean             => { sub   => sub { $_[0]->part->ean },
811                          text  => t8('EAN'), },
812     partnumber      => { sub   => sub { $_[0]->part->partnumber },
813                          text  => t8('Part Number'), },
814     part            => { sub   => sub { $_[0]->part->description },
815                          text  => t8('Part Description'), },
816     qty             => { sub   => sub { $_[0]->qty_as_number },
817                          text  => t8('Target Qty'),
818                          align => 'right', },
819     unit            => { sub   => sub { $_[0]->part->unit },
820                          text  => t8('Unit'), },
821     bin             => { sub   => sub { $_[0]->bin->full_description },
822                          text  => t8('Bin'), },
823     chargenumber    => { text  => t8('Charge Number'), },
824     comment         => { text  => t8('Comment'), },
825     cutoff_date     => { sub   => sub { $_[0]->cutoff_date_as_date },
826                          text  => t8('Cutoff Date'), },
827   );
828
829   $report->set_options(
830     std_column_visibility => 1,
831     controller_class      => 'Inventory',
832     output_format         => 'HTML',
833     title                 => (!!$params{full})? $::locale->text('Stocktaking Journal') : $::locale->text('Stocktaking History'),
834     allow_pdf_export      => !!$params{full},
835     allow_csv_export      => !!$params{full},
836   );
837   $report->set_columns(%column_defs);
838   $report->set_column_order(@columns);
839   $report->set_export_options(qw(stocktaking_journal filter));
840   $report->set_options_from_form;
841   $self->stocktaking_models->disable_plugin('paginated') if $report->{options}{output_format} =~ /^(pdf|csv)$/i;
842   $self->stocktaking_models->set_report_generator_sort_options(report => $report, sortable_columns => \@sortable) if !!$params{full};
843   if (!!$params{full}) {
844     $report->set_options(
845       raw_top_info_text    => $self->render('inventory/stocktaking/full_report_top', { output => 0 }),
846     );
847   }
848   $report->set_options(
849     raw_bottom_info_text => $self->render('inventory/stocktaking/report_bottom',   { output => 0 }),
850   );
851 }
852
853 sub _get_stocked_qty {
854   my ($part, %params) = @_;
855
856   my $bestbefore_filter  = '';
857   my $bestbefore_val_cnt = 0;
858   if ($::instance_conf->get_show_bestbefore) {
859     $bestbefore_filter  = ($params{bestbefore}) ? 'AND bestbefore = ?' : 'AND bestbefore IS NULL';
860     $bestbefore_val_cnt = ($params{bestbefore}) ? 1                    : 0;
861   }
862
863   my $query = <<SQL;
864     SELECT sum(qty) FROM inventory
865       WHERE parts_id = ? AND warehouse_id = ? AND bin_id = ? AND chargenumber = ? $bestbefore_filter
866       GROUP BY warehouse_id, bin_id, chargenumber
867 SQL
868
869   my @values = ($part->id,
870                 $params{warehouse_id},
871                 $params{bin_id},
872                 $params{chargenumber});
873   push @values, $params{bestbefore} if $bestbefore_val_cnt;
874
875   my ($stocked_qty) = selectrow_query($::form, $::form->get_standard_dbh, $query, @values);
876
877   return 1*($stocked_qty || 0);
878 }
879
880 sub _already_counted {
881   my ($part, %params) = @_;
882
883   my %bestbefore_filter;
884   if ($::instance_conf->get_show_bestbefore) {
885     %bestbefore_filter = (bestbefore => $params{bestbefore});
886   }
887
888   SL::DB::Manager::Stocktaking->get_all(query => [and => [parts_id     => $part->id,
889                                                           warehouse_id => $params{warehouse_id},
890                                                           bin_id       => $params{bin_id},
891                                                           cutoff_date  => $params{cutoff_date},
892                                                           chargenumber => $params{chargenumber},
893                                                           %bestbefore_filter]],
894                                         sort_by => ['itime DESC']);
895 }
896
897 sub setup_stock_in_action_bar {
898   my ($self, %params) = @_;
899
900   for my $bar ($::request->layout->get('actionbar')) {
901     $bar->add(
902       action => [
903         t8('Stock'),
904         submit    => [ '#form', { action => 'Inventory/stock' } ],
905         checks    => [ 'check_part_selection_before_stocking' ],
906         accesskey => 'enter',
907       ],
908     );
909   }
910 }
911
912 sub setup_stock_usage_action_bar {
913   my ($self, %params) = @_;
914
915   for my $bar ($::request->layout->get('actionbar')) {
916     $bar->add(
917       action => [
918         t8('Show'),
919         submit    => [ '#form', { action => 'Inventory/usage' } ],
920         accesskey => 'enter',
921       ],
922     );
923   }
924 }
925
926 sub setup_stock_stocktaking_action_bar {
927   my ($self, %params) = @_;
928
929   for my $bar ($::request->layout->get('actionbar')) {
930     $bar->add(
931       action => [
932         t8('Save'),
933         call      => [ 'kivi.Inventory.save_stocktaking' ],
934         accesskey => 'enter',
935       ],
936     );
937   }
938 }
939
940 1;
941 __END__
942
943 =encoding utf-8
944
945 =head1 NAME
946
947 SL::Controller::Inventory - Controller for inventory
948
949 =head1 DESCRIPTION
950
951 This controller handles stock in, stocktaking and reports about inventory
952 in warehouses/stocks
953
954 - warehouse content
955
956 - warehouse journal
957
958 - warehouse withdrawal
959
960 - stocktaking
961
962 =head2 Stocktaking
963
964 Stocktaking allows to document the counted quantities of parts during
965 stocktaking for a certain cutoff date. Differences between counted and stocked
966 quantities are corrected in the stock. The transfer type 'stocktacking' is set
967 here.
968
969 After picking a part, the mini stock for this part is displayed. At the bottom
970 of the form a history of already counted parts for the current employee and the
971 choosen cutoff date is shown.
972
973 Warehouse, bin and cutoff date canbe preselected in the client configuration.
974
975 If a part was already counted for this cutoff date, warehouse and bin, a warning
976 is displayed, allowing the user to choose to add the counted quantity to the
977 stocked one or to take his counted quantity as the new stocked quantity.
978
979 There is also a journal of stocktakings.
980
981 Templates are located under C<templates/webpages/inventory/stocktaking>.
982 JavaScript functions can be found in C<js/kivi.Inventory.js>.
983
984 =head1 FUNCTIONS
985
986 =over 4
987
988 =item C<action_stock_usage>
989
990 Create a search form for stock withdrawal.
991 The search parameter for report are made like the reports in bin/mozilla/rp.pl
992
993 =item C<action_usage>
994
995 Make a report about stock withdrawal.
996
997 The manual pagination is implemented like the pagination in SL::Controller::CsvImport.
998
999 =item C<action_stocktaking>
1000
1001 This action renders the input form for stocktaking.
1002
1003 =item C<action_save_stocktaking>
1004
1005 This action saves the stocktaking values and corrects the stock after checking
1006 if the part is already counted for this warehouse, bin and cutoff date.
1007 For saving SL::WH->transfer is called.
1008
1009 =item C<action_reload_stocktaking_history>
1010
1011 This action is responsible for displaying the stocktaking history at the bottom
1012 of the form. It uses the stocktaking journal with fixed filters for cutoff date
1013 and the current employee. The history is displayed via javascript.
1014
1015 =item C<action_stocktaking_part_changed>
1016
1017 This action is called after the user selected or changed the part.
1018
1019 =item C<is_stocktaking>
1020
1021 This is a method to check if actions are called from stocktaking form.
1022
1023 =back
1024
1025 =head1 SPECIAL CASES
1026
1027 Because of the PFD-Table Formatter some parameters for PDF must be different to the HTML parameters.
1028 So in german language there are some tries to use a HTML Break in the second heading line
1029 to produce two line heading inside table. The actual version has some abbreviations for the header texts.
1030
1031 =head1 BUGS
1032
1033 The PDF-Table library has some limits (doesn't display all if the line is to large) so
1034 the format is adapted to this
1035
1036
1037 =head1 AUTHOR
1038
1039 =over 4
1040
1041 =item only for C<action_stock_usage> and C<action_usage>:
1042
1043 Martin Helmling E<lt>martin.helmling@opendynamic.deE<gt>
1044
1045 =item for stocktaking:
1046
1047 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>
1048
1049 =back
1050
1051 =cut