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