1 package SL::Controller::Inventory;
5 use POSIX qw(strftime);
7 use parent qw(SL::Controller::Base);
10 use SL::DB::Stocktaking;
12 use SL::DB::Warehouse;
16 use SL::ReportGenerator;
17 use SL::Locale::String qw(t8);
18 use SL::Presenter::Tag qw(select_tag);
20 use SL::Helper::Flash;
21 use SL::Controller::Helper::ReportGenerator;
22 use SL::Controller::Helper::GetModels;
23 use List::MoreUtils qw(uniq);
25 use English qw(-no_match_vars);
27 use Rose::Object::MakeMethods::Generic (
28 'scalar --get_set_init' => [ qw(warehouses units is_stocktaking stocktaking_models stocktaking_cutoff_date) ],
29 'scalar' => [ qw(warehouse bin unit part) ],
32 __PACKAGE__->run_before('_check_auth');
33 __PACKAGE__->run_before('_check_warehouses');
34 __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) ]);
35 __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) ]);
36 __PACKAGE__->run_before('load_wh_from_form', only => [ qw(stock_in warehouse_changed stock stocktaking stocktaking_get_warn_qty_threshold save_stocktaking) ]);
37 __PACKAGE__->run_before('load_bin_from_form', only => [ qw(stock_in stock stocktaking stocktaking_get_warn_qty_threshold save_stocktaking) ]);
38 __PACKAGE__->run_before('set_target_from_part', only => [ qw(part_changed) ]);
39 __PACKAGE__->run_before('mini_stock', only => [ qw(stock_in mini_stock) ]);
40 __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) ]);
41 __PACKAGE__->run_before('set_layout');
46 $::form->{title} = t8('Stock');
48 # Sometimes we want to open stock_in with a part already selected, but only
49 # the parts_id is passed in the url (and not also warehouse, bin and unit).
50 # Setting select_default_bin in the form will make sure the default warehouse
51 # and bin of that part will already be preselected, as normally
52 # set_target_from_part is only called when a part is changed.
53 $self->set_target_from_part if $::form->{select_default_bin};
54 $::request->layout->focus('#part_id_name');
55 my $transfer_types = WH->retrieve_transfer_types('in');
56 map { $_->{description} = $main::locale->text($_->{description}) } @{ $transfer_types };
57 $self->setup_stock_in_action_bar;
58 $self->render('inventory/warehouse_selection_stock', title => $::form->{title}, TRANSFER_TYPES => $transfer_types );
61 sub action_stock_usage {
64 $::form->{title} = t8('UsageE');
66 $::form->get_lists('warehouses' => { 'key' => 'WAREHOUSES',
69 $self->setup_stock_usage_action_bar;
70 $self->render('inventory/warehouse_usage',
71 title => $::form->{title},
72 year => DateTime->today->year,
73 WAREHOUSES => $::form->{WAREHOUSES},
74 WAREHOUSE_FILTER => 1,
83 return qw(stock incorrection found insum back outcorrection disposed
84 missing shipped used outsum consumed averconsumed);
90 $main::lxdebug->enter_sub();
92 my $form = $main::form;
93 my %myconfig = %main::myconfig;
94 my $locale = $main::locale;
96 $form->{title} = t8('UsageE');
97 $form->{report_generator_output_format} = 'HTML' if !$form->{report_generator_output_format};
99 my $report = SL::ReportGenerator->new(\%myconfig, $form);
101 my @columns = qw(partnumber partdescription);
103 push @columns , qw(ptype unit) if $form->{report_generator_output_format} eq 'HTML';
105 my @numcolumns = qw(stock incorrection found insum back outcorrection disposed
106 missing shipped used outsum consumed averconsumed);
108 push @columns , $self->getnumcolumns();
110 my @hidden_variables = qw(reporttype year duetyp fromdate todate
111 warehouse_id bin_id partnumber description bestbefore chargenumber partstypes_id);
113 'partnumber' => { 'text' => $locale->text('Part Number'), },
114 'partdescription' => { 'text' => $locale->text('Part_br_Description'), },
115 'unit' => { 'text' => $locale->text('Unit'), },
116 'stock' => { 'text' => $locale->text('stock_br'), },
117 'incorrection' => { 'text' => $locale->text('correction_br'), },
118 'found' => { 'text' => $locale->text('found_br'), },
119 'insum' => { 'text' => $locale->text('sum'), },
120 'back' => { 'text' => $locale->text('back_br'), },
121 'outcorrection' => { 'text' => $locale->text('correction_br'), },
122 'disposed' => { 'text' => $locale->text('disposed_br'), },
123 'missing' => { 'text' => $locale->text('missing_br'), },
124 'shipped' => { 'text' => $locale->text('shipped_br'), },
125 'used' => { 'text' => $locale->text('used_br'), },
126 'outsum' => { 'text' => $locale->text('sum'), },
127 'consumed' => { 'text' => $locale->text('consumed'), },
128 'averconsumed' => { 'text' => $locale->text('averconsumed_br'), },
132 map { $column_defs{$_}->{visible} = 1 } @columns;
133 #map { $column_defs{$_}->{visible} = $form->{"l_${_}"} ? 1 : 0 } @columns;
134 map { $column_defs{$_}->{align} = 'right' } @numcolumns;
136 my @custom_headers = ();
138 push @custom_headers, [
139 { 'text' => $locale->text('Part'),
140 'colspan' => ($form->{report_generator_output_format} eq 'HTML'?4:2), 'align' => 'center'},
141 { 'text' => $locale->text('Into bin'), 'colspan' => 4, 'align' => 'center'},
142 { 'text' => $locale->text('From bin'), 'colspan' => 7, 'align' => 'center'},
143 { 'text' => $locale->text('UsageWithout'), 'colspan' => 2, 'align' => 'center'},
148 map { push @line_2 , $column_defs{$_} } @columns;
149 push @custom_headers, [ @line_2 ];
151 $report->set_custom_headers(@custom_headers);
152 $report->set_columns( %column_defs );
153 $report->set_column_order(@columns);
155 $report->set_export_options('usage', @hidden_variables );
157 $report->set_sort_indicator($form->{sort}, $form->{order});
158 $report->set_options('output_format' => 'HTML',
159 'controller_class' => 'Inventory',
160 'title' => $form->{title},
161 # 'html_template' => 'inventory/usage_report',
162 'attachment_basename' => strftime($locale->text('warehouse_usage_list') . '_%Y%m%d', localtime time));
163 $report->set_options_from_form;
167 # reporttype = custom
171 my $start = DateTime->now_local;
172 my $end = DateTime->now_local;
173 my $actualepoch = $end->epoch();
176 $searchparams{reporttype} = $form->{reporttype};
177 if ($form->{reporttype} eq "custom") {
182 #forgotten the year --> thisyear
183 if ($form->{year} !~ m/^\d\d\d\d$/) {
184 $locale->date(\%myconfig, $form->current_date(\%myconfig), 0) =~
188 my $leapday = ($form->{year} % 4 == 0) ? 1:0;
190 if ($form->{duetyp} eq "13") {
195 if ($form->{duetyp} eq "A") {
197 $days = 90 + $leapday;
199 if ($form->{duetyp} eq "B") {
205 if ($form->{duetyp} eq "C") {
211 if ($form->{duetyp} eq "D") {
216 if ($form->{duetyp} eq "1" || $form->{duetyp} eq "3" || $form->{duetyp} eq "5" ||
217 $form->{duetyp} eq "7" || $form->{duetyp} eq "8" || $form->{duetyp} eq "10" ||
218 $form->{duetyp} eq "12") {
219 $smon = $emon = $form->{duetyp}*1;
222 if ($form->{duetyp} eq "2" || $form->{duetyp} eq "4" || $form->{duetyp} eq "6" ||
223 $form->{duetyp} eq "9" || $form->{duetyp} eq "11" ) {
224 $smon = $emon = $form->{duetyp}*1;
226 if ($form->{duetyp} eq "2" ) {
227 #this works from 1901 to 2099, 1900 and 2100 fail.
228 $eday = ($form->{year} % 4 == 0) ? 29 : 28;
230 $mdays=$days = $eday;
232 $searchparams{year} = $form->{year};
233 $searchparams{duetyp} = $form->{duetyp};
234 $start->set_month($smon);
235 $start->set_day($sday);
236 $start->set_year($form->{year}*1);
237 $end->set_month($emon);
238 $end->set_day($eday);
239 $end->set_year($form->{year}*1);
241 $searchparams{fromdate} = $form->{fromdate};
242 $searchparams{todate} = $form->{todate};
244 # fromdate = 01.01.2014
245 # todate = 31.05.2014
246 my ($yy, $mm, $dd) = $locale->parse_date(\%myconfig,$form->{fromdate});
247 $start->set_year($yy);
248 $start->set_month($mm);
249 $start->set_day($dd);
250 ($yy, $mm, $dd) = $locale->parse_date(\%myconfig,$form->{todate});
252 $end->set_month($mm);
254 my $dur = $start->delta_md($end);
255 $days = $dur->delta_months()*30 + $dur->delta_days() ;
257 $start->set_second(0);
258 $start->set_minute(0);
260 $end->set_second(59);
261 $end->set_minute(59);
263 if ( $end->epoch() > $actualepoch ) {
264 $end = DateTime->now_local;
265 my $dur = $start->delta_md($end);
266 $days = $dur->delta_months()*30 + $dur->delta_days() ;
268 if ( $start->epoch() > $end->epoch() ) { $start = $end;$days = 1;}
269 $days = $mdays if $days < $mdays;
270 #$main::lxdebug->message(LXDebug->DEBUG2(), "start=".$start->epoch());
271 #$main::lxdebug->message(LXDebug->DEBUG2(), " end=".$end->epoch());
272 #$main::lxdebug->message(LXDebug->DEBUG2(), " days=".$days);
273 my @andfilter = (shippingdate => { ge => $start }, shippingdate => { le => $end } );
274 if ( $form->{warehouse_id} ) {
275 push @andfilter , ( warehouse_id => $form->{warehouse_id});
276 $searchparams{warehouse_id} = $form->{warehouse_id};
277 if ( $form->{bin_id} ) {
278 push @andfilter , ( bin_id => $form->{bin_id});
279 $searchparams{bin_id} = $form->{bin_id};
282 # alias class t2 entspricht parts
283 if ( $form->{partnumber} ) {
284 push @andfilter , ( 't2.partnumber' => { ilike => '%'. $form->{partnumber} .'%' });
285 $searchparams{partnumber} = $form->{partnumber};
287 if ( $form->{description} ) {
288 push @andfilter , ( 't2.description' => { ilike => '%'. $form->{description} .'%' });
289 $searchparams{description} = $form->{description};
291 if ( $form->{bestbefore} ) {
292 push @andfilter , ( bestbefore => { eq => $form->{bestbefore} });
293 $searchparams{bestbefore} = $form->{bestbefore};
295 if ( $form->{chargenumber} ) {
296 push @andfilter , ( chargenumber => { ilike => '%'.$form->{chargenumber}.'%' });
297 $searchparams{chargenumber} = $form->{chargenumber};
299 if ( $form->{partstypes_id} ) {
300 push @andfilter , ( 't2.partstypes_id' => $form->{partstypes_id} );
301 $searchparams{partstypes_id} = $form->{partstypes_id};
304 my @filter = (and => [ @andfilter ] );
306 my $objs = SL::DB::Manager::Inventory->get_all(with_objects => ['parts'], where => [ @filter ] , sort_by => 'parts.partnumber ASC');
307 #my $objs = SL::DB::Inventory->_get_manager_class->get_all(...);
309 # manual paginating, yuck
310 my $page = $::form->{page} || 1;
312 $pages->{per_page} = $::form->{per_page} || 20;
313 my $first_nr = ($page - 1) * $pages->{per_page};
314 my $last_nr = $first_nr + $pages->{per_page};
320 $allrows = 1 if $form->{report_generator_output_format} ne 'HTML' ;
321 #$main::lxdebug->message(LXDebug->DEBUG2(), "first_nr=".$first_nr." last_nr=".$last_nr);
322 foreach my $entry (@{ $objs } ) {
323 if ( $entry->parts_id != $last_partid ) {
324 if ( $last_partid > 0 ) {
325 if ( $allrows || ($row_ind >= $first_nr && $row_ind < $last_nr )) {
326 $self->make_row_result($last_row,$days,$last_partid);
327 $report->add_data($last_row);
331 $last_partid = $entry->parts_id;
333 $last_row->{partnumber}->{data} = $entry->part->partnumber;
334 $last_row->{partdescription}->{data} = $entry->part->description;
335 $last_row->{unit}->{data} = $entry->part->unit;
336 $last_row->{stock}->{data} = 0;
337 $last_row->{incorrection}->{data} = 0;
338 $last_row->{found}->{data} = 0;
339 $last_row->{back}->{data} = 0;
340 $last_row->{outcorrection}->{data} = 0;
341 $last_row->{disposed}->{data} = 0;
342 $last_row->{missing}->{data} = 0;
343 $last_row->{shipped}->{data} = 0;
344 $last_row->{used}->{data} = 0;
345 $last_row->{insum}->{data} = 0;
346 $last_row->{outsum}->{data} = 0;
347 $last_row->{consumed}->{data} = 0;
348 $last_row->{averconsumed}->{data} = 0;
350 if ( !$allrows && $row_ind >= $last_nr ) {
354 if ( $entry->trans_type->description eq 'correction' ) {
355 $prefix = $entry->trans_type->direction;
357 $last_row->{$prefix.$entry->trans_type->description}->{data} +=
358 ( $entry->trans_type->direction eq 'out' ? -$entry->qty : $entry->qty );
360 if ( $last_partid > 0 && ( $allrows || ($row_ind >= $first_nr && $row_ind < $last_nr ))) {
361 $self->make_row_result($last_row,$days,$last_partid);
362 $report->add_data($last_row);
365 my $num_rows = @{ $report->{data} } ;
366 #$main::lxdebug->message(LXDebug->DEBUG2(), "count=".$row_ind." rows=".$num_rows);
369 $pages->{max} = SL::DB::Helper::Paginated::ceil($row_ind, $pages->{per_page}) || 1;
370 $pages->{page} = $page < 1 ? 1: $page > $pages->{max} ? $pages->{max}: $page;
371 $pages->{common} = [ grep { $_->{visible} } @{ SL::DB::Helper::Paginated::make_common_pages($pages->{page}, $pages->{max}) } ];
372 $self->{pages} = $pages;
373 $searchparams{action} = "usage";
374 $self->{base_url} = $self->url_for(\%searchparams );
375 #$main::lxdebug->message(LXDebug->DEBUG2(), "page=".$pages->{page}." url=".$self->{base_url});
377 $report->set_options('raw_bottom_info_text' => $self->render('inventory/report_bottom', { output => 0 }) );
379 $report->generate_with_headers();
381 $main::lxdebug->leave_sub();
385 sub make_row_result {
386 my ($self,$row,$days,$partid) = @_;
387 my $form = $main::form;
388 my $myconfig = \%main::myconfig;
390 $row->{insum}->{data} = $row->{stock}->{data} + $row->{incorrection}->{data} + $row->{found}->{data};
391 $row->{outsum}->{data} = $row->{back}->{data} + $row->{outcorrection}->{data} + $row->{disposed}->{data} +
392 $row->{missing}->{data} + $row->{shipped}->{data} + $row->{used}->{data};
393 $row->{consumed}->{data} = $row->{outsum}->{data} -
394 $row->{outcorrection}->{data} - $row->{incorrection}->{data};
395 $row->{averconsumed}->{data} = $row->{consumed}->{data}*30/$days ;
396 map { $row->{$_}->{data} = $form->format_amount($myconfig,$row->{$_}->{data},2); } $self->getnumcolumns();
397 $row->{partnumber}->{link} = 'controller.pl?action=Part/edit&part.id' . $partid;
404 my $qty = $::form->parse_amount(\%::myconfig, $::form->{qty});
406 $transfer_error = t8('Cannot stock without amount');
408 $transfer_error = t8('Cannot stock negative amounts');
411 $::form->throw_on_error(sub {
414 parts => $self->part,
415 dst_bin => $self->bin,
416 dst_wh => $self->warehouse,
419 transfer_type => 'stock',
420 transfer_type_id => $::form->{transfer_type_id},
421 chargenumber => $::form->{chargenumber},
422 bestbefore => $::form->{bestbefore},
423 comment => $::form->{comment},
426 } or do { $transfer_error = $EVAL_ERROR->error; }
429 if (!$transfer_error) {
430 if ($::form->{write_default_bin}) {
431 $self->part->load; # onhand is calculated in between. don't mess that up
432 $self->part->bin($self->bin);
433 $self->part->warehouse($self->warehouse);
437 flash_later('info', t8('Transfer successful'));
441 my %additional_redirect_params = ();
442 if ($transfer_error) {
443 flash_later('error', $transfer_error);
444 $additional_redirect_params{$_} = $::form->{$_} for qw(qty chargenumber bestbefore ean comment);
445 $additional_redirect_params{qty} = $qty;
450 action => 'stock_in',
451 part_id => $self->part->id,
452 bin_id => $self->bin->id,
453 warehouse_id => $self->warehouse->id,
454 unit_id => $self->unit->id,
455 %additional_redirect_params,
459 sub action_part_changed {
462 # no standard? ask user if he wants to write it
463 if ($self->part->id && !$self->part->bin_id && !$self->part->warehouse_id) {
464 $self->js->show('#write_default_bin_span');
466 $self->js->hide('#write_default_bin_span')
467 ->removeAttr('#write_default_bin', 'checked');
471 ->replaceWith('#warehouse_id', $self->build_warehouse_select)
472 ->replaceWith('#bin_id', $self->build_bin_select)
473 ->replaceWith('#unit_id', $self->build_unit_select)
474 ->focus('#warehouse_id')
478 sub action_warehouse_changed {
482 ->replaceWith('#bin_id', $self->build_bin_select)
487 sub action_mini_stock {
491 ->html('#stock', $self->render('inventory/_stock', { output => 0 }))
495 sub action_stocktaking {
498 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.Inventory);
499 $::request->layout->focus('#part_id_name');
500 $self->setup_stock_stocktaking_action_bar;
501 $self->render('inventory/stocktaking/form', title => t8('Stocktaking'));
504 sub action_save_stocktaking {
507 return $self->js->flash('error', t8('Please choose a part.'))->render()
508 if !$::form->{part_id};
510 return $self->js->flash('error', t8('A target quantitiy has to be given'))->render()
511 if $::form->{target_qty} eq '';
513 my $target_qty = $::form->parse_amount(\%::myconfig, $::form->{target_qty});
515 return $self->js->flash('error', t8('Error: A negative target quantity is not allowed.'))->render()
518 my $stocked_qty = _get_stocked_qty($self->part,
519 warehouse_id => $self->warehouse->id,
520 bin_id => $self->bin->id,
521 chargenumber => $::form->{chargenumber},
522 bestbefore => $::form->{bestbefore},);
524 my $stocked_qty_in_form_units = $self->part->unit_obj->convert_to($stocked_qty, $self->unit);
526 if (!$::form->{dont_check_already_counted}) {
527 my $already_counted = _already_counted($self->part,
528 warehouse_id => $self->warehouse->id,
529 bin_id => $self->bin->id,
530 cutoff_date => $::form->{cutoff_date_as_date},
531 chargenumber => $::form->{chargenumber},
532 bestbefore => $::form->{bestbefore});
533 if (scalar @$already_counted) {
534 my $reply = $self->js->dialog->open({
535 html => $self->render('inventory/stocktaking/_already_counted_dialog',
537 already_counted => $already_counted,
538 stocked_qty => $stocked_qty,
539 stocked_qty_in_form_units => $stocked_qty_in_form_units),
540 id => 'already_counted_dialog',
542 title => t8('Already counted'),
550 # - target_qty is in units given in form ($self->unit)
551 # - WH->transfer expects qtys in given unit (here: unit from form (unit -> $self->unit))
552 # Therefore use stocked_qty in form units for calculation.
553 my $qty = $target_qty - $stocked_qty_in_form_units;
554 my $src_or_dst = $qty < 0? 'src' : 'dst';
559 $::form->throw_on_error(sub {
562 parts => $self->part,
563 $src_or_dst.'_bin' => $self->bin,
564 $src_or_dst.'_wh' => $self->warehouse,
567 transfer_type => 'stocktaking',
568 chargenumber => $::form->{chargenumber},
569 bestbefore => $::form->{bestbefore},
570 ean => $::form->{ean},
571 comment => $::form->{comment},
572 record_stocktaking => 1,
573 stocktaking_qty => $target_qty,
574 stocktaking_cutoff_date => $::form->{cutoff_date_as_date},
577 } or do { $transfer_error = $EVAL_ERROR->error; }
580 return $self->js->flash('error', $transfer_error)->render()
583 flash_later('info', $::locale->text('Part successful counted'));
584 $self->redirect_to(action => 'stocktaking',
585 warehouse_id => $self->warehouse->id,
586 bin_id => $self->bin->id,
587 cutoff_date_as_date => $self->stocktaking_cutoff_date->to_kivitendo);
590 sub action_reload_stocktaking_history {
593 $::form->{filter}{'cutoff_date:date'} = $self->stocktaking_cutoff_date->to_kivitendo;
594 $::form->{filter}{'employee_id'} = SL::DB::Manager::Employee->current->id;
596 $self->prepare_stocktaking_report;
597 $self->report_generator_list_objects(report => $self->{report}, objects => $self->stocktaking_models->get, layout => 0, header => 0);
600 sub action_stocktaking_part_changed {
604 ->replaceWith('#unit_id', $self->build_unit_select)
605 ->focus('#target_qty')
609 sub action_stocktaking_journal {
612 $self->prepare_stocktaking_report(full => 1);
613 $self->report_generator_list_objects(report => $self->{report}, objects => $self->stocktaking_models->get);
616 sub action_stocktaking_get_warn_qty_threshold {
619 return $_[0]->render(\ !!0, { type => 'text' }) if !$::form->{part_id};
620 return $_[0]->render(\ !!0, { type => 'text' }) if $::form->{target_qty} eq '';
621 return $_[0]->render(\ !!0, { type => 'text' }) if 0 == $::instance_conf->get_stocktaking_qty_threshold;
623 my $target_qty = $::form->parse_amount(\%::myconfig, $::form->{target_qty});
624 my $stocked_qty = _get_stocked_qty($self->part,
625 warehouse_id => $self->warehouse->id,
626 bin_id => $self->bin->id,
627 chargenumber => $::form->{chargenumber},
628 bestbefore => $::form->{bestbefore},);
629 my $stocked_qty_in_form_units = $self->part->unit_obj->convert_to($stocked_qty, $self->unit);
630 my $qty = $target_qty - $stocked_qty_in_form_units;
634 if ($qty > $::instance_conf->get_stocktaking_qty_threshold) {
635 $warn = t8('The target quantity of #1 differs more than the threshold quantity of #2.',
636 $::form->{target_qty} . " " . $self->unit->name,
637 $::form->format_amount(\%::myconfig, $::instance_conf->get_stocktaking_qty_threshold, 2));
639 $warn .= t8('Choose "continue" if you want to use this value. Choose "cancel" otherwise.');
641 return $_[0]->render(\ $warn, { type => 'text' });
644 #================================================================
647 $main::auth->assert('warehouse_management');
650 sub _check_warehouses {
651 $_[0]->show_no_warehouses_error if !@{ $_[0]->warehouses };
654 sub init_warehouses {
655 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef ]]);
659 # SL::DB::Manager::Bin->get_all();
663 SL::DB::Manager::Unit->get_all;
666 sub init_is_stocktaking {
667 return $_[0]->action_name =~ m{stocktaking};
670 sub init_stocktaking_models {
673 SL::Controller::Helper::GetModels->new(
675 model => 'Stocktaking',
681 itime => t8('Insert Date'),
682 qty => t8('Target Qty'),
683 chargenumber => t8('Charge Number'),
684 comment => t8('Comment'),
685 employee => t8('Employee'),
687 partnumber => t8('Part Number'),
688 part => t8('Part Description'),
690 cutoff_date => t8('Cutoff Date'),
692 with_objects => ['employee', 'parts', 'warehouse', 'bin'],
696 sub init_stocktaking_cutoff_date {
699 return DateTime->from_kivitendo($::form->{cutoff_date_as_date}) if $::form->{cutoff_date_as_date};
700 return SL::DB::Default->get->stocktaking_cutoff_date if SL::DB::Default->get->stocktaking_cutoff_date;
702 # Default cutoff date is last day of current year, but if current month
703 # is janurary, it is the last day of the last year.
704 my $now = DateTime->now_local;
705 my $cutoff = DateTime->new(year => $now->year, month => 12, day => 31);
706 if ($now->month < 1) {
707 $cutoff->substract(years => 1);
712 sub set_target_from_part {
715 return if !$self->part;
717 $self->warehouse($self->part->warehouse) if $self->part->warehouse;
718 $self->bin( $self->part->bin) if $self->part->bin;
721 sub sanitize_target {
724 $self->warehouse($self->warehouses->[0]) if !$self->warehouse || !$self->warehouse->id;
725 $self->bin ($self->warehouse->bins->[0]) if !$self->bin || !$self->bin->id;
726 # foreach my $warehouse ( $self->warehouses ) {
727 # $warehouse->{BINS} = [];
728 # foreach my $bin ( $self->bins ) {
729 # if ( $bin->warehouse_id == $warehouse->id ) {
730 # push @{ $warehouse->{BINS} }, $bin;
736 sub load_part_from_form {
737 $_[0]->part(SL::DB::Manager::Part->find_by_or_create(id => $::form->{part_id}||undef));
740 sub load_unit_from_form {
741 $_[0]->unit(SL::DB::Manager::Unit->find_by_or_create(id => $::form->{unit_id}));
744 sub load_wh_from_form {
746 $preselected = SL::DB::Default->get->stocktaking_warehouse_id if $_[0]->is_stocktaking;
748 $_[0]->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => ($::form->{warehouse_id} || $preselected)));
751 sub load_bin_from_form {
753 $preselected = SL::DB::Default->get->stocktaking_bin_id if $_[0]->is_stocktaking;
755 $_[0]->bin(SL::DB::Manager::Bin->find_by_or_create(id => ($::form->{bin_id} || $preselected)));
759 $::request->layout->add_javascripts('client_js.js');
762 sub build_warehouse_select {
763 select_tag('warehouse_id', $_[0]->warehouses,
764 title_key => 'description',
765 default => $_[0]->warehouse->id,
766 onchange => 'reload_bin_selection()',
770 sub build_bin_select {
771 select_tag('bin_id', [ $_[0]->warehouse->bins ],
772 title_key => 'description',
773 default => $_[0]->bin->id,
777 sub build_unit_select {
779 ? select_tag('unit_id', $_[0]->part->available_units,
781 default => $_[0]->part->unit_obj->id,
783 : select_tag('unit_id', $_[0]->units,
791 # We want to fetch the last 10 inventory events (inventory rows with the same trans_id)
792 # To prevent a Seq Scan on inventory set an index on inventory.itime
793 # Each event may have one (transfer_in/out) or two (transfer) inventory rows
794 # So fetch the last 20, group by trans_id, limit to the last 10 trans_ids,
795 # and then extract the inventory ids from those 10 trans_ids
796 # By querying Inventory->get_all via the id instead of trans_id we can make
797 # use of the existing index on id
799 # inventory ids of the most recent 10 inventory trans_ids
801 with last_inventories as (
812 from last_inventories
819 limit 20 -- so the planner knows how many ids to expect, the cte is an optimisation fence
822 my $objs = SL::DB::Manager::Inventory->get_all(
823 query => [ id => [ \"$query" ] ], # " make emacs happy
824 with_objects => [ 'parts', 'trans_type', 'bin', 'bin.warehouse' ], # prevent lazy loading in template
825 sort_by => 'itime DESC',
827 # remember order of trans_ids from query, for ordering hash later
828 my @sorted_trans_ids = uniq map { $_->trans_id } @$objs;
830 # at most 2 of them belong to a transaction and the qty determines in or out.
833 $transactions{ $_->trans_id }{ $_->qty > 0 ? 'in' : 'out' } = $_;
834 $transactions{ $_->trans_id }{base} = $_;
837 # because the inventory transactions were built in a hash, we need to sort the
838 # hash by using the original sort order of the trans_ids
839 my @sorted = map { $transactions{$_} } @sorted_trans_ids;
847 my $stock = $self->part->get_simple_stock;
848 $self->{stock_by_bin} = { map { $_->{bin_id} => $_ } @$stock };
849 $self->{stock_empty} = ! grep { $_->{sum} * 1 } @$stock;
852 sub show_no_warehouses_error {
855 my $msg = t8('No warehouse has been created yet or the quantity of the bins is not configured yet.') . ' ';
857 if ($::auth->check_right($::myconfig{login}, 'config')) { # TODO wut?
858 $msg .= t8('You can create warehouses and bins via the menu "System -> Warehouses".');
860 $msg .= t8('Please ask your administrator to create warehouses and bins.');
862 $::form->show_generic_error($msg);
865 sub prepare_stocktaking_report {
866 my ($self, %params) = @_;
868 my $callback = $self->stocktaking_models->get_callback;
870 my $report = SL::ReportGenerator->new(\%::myconfig, $::form);
871 $self->{report} = $report;
873 my @columns = qw(itime employee ean partnumber part qty unit bin chargenumber comment cutoff_date);
874 my @sortable = qw(itime employee ean partnumber part qty bin chargenumber comment cutoff_date);
877 itime => { sub => sub { $_[0]->itime_as_timestamp },
878 text => t8('Insert Date'), },
879 employee => { sub => sub { $_[0]->employee->safe_name },
880 text => t8('Employee'), },
881 ean => { sub => sub { $_[0]->part->ean },
882 text => t8('EAN'), },
883 partnumber => { sub => sub { $_[0]->part->partnumber },
884 text => t8('Part Number'), },
885 part => { sub => sub { $_[0]->part->description },
886 text => t8('Part Description'), },
887 qty => { sub => sub { $_[0]->qty_as_number },
888 text => t8('Target Qty'),
890 unit => { sub => sub { $_[0]->part->unit },
891 text => t8('Unit'), },
892 bin => { sub => sub { $_[0]->bin->full_description },
893 text => t8('Bin'), },
894 chargenumber => { text => t8('Charge Number'), },
895 comment => { text => t8('Comment'), },
896 cutoff_date => { sub => sub { $_[0]->cutoff_date_as_date },
897 text => t8('Cutoff Date'), },
900 $report->set_options(
901 std_column_visibility => 1,
902 controller_class => 'Inventory',
903 output_format => 'HTML',
904 title => (!!$params{full})? $::locale->text('Stocktaking Journal') : $::locale->text('Stocktaking History'),
905 allow_pdf_export => !!$params{full},
906 allow_csv_export => !!$params{full},
908 $report->set_columns(%column_defs);
909 $report->set_column_order(@columns);
910 $report->set_export_options(qw(stocktaking_journal filter));
911 $report->set_options_from_form;
912 $self->stocktaking_models->disable_plugin('paginated') if $report->{options}{output_format} =~ /^(pdf|csv)$/i;
913 $self->stocktaking_models->set_report_generator_sort_options(report => $report, sortable_columns => \@sortable) if !!$params{full};
914 if (!!$params{full}) {
915 $report->set_options(
916 raw_top_info_text => $self->render('inventory/stocktaking/full_report_top', { output => 0 }),
919 $report->set_options(
920 raw_bottom_info_text => $self->render('inventory/stocktaking/report_bottom', { output => 0 }),
924 sub _get_stocked_qty {
925 my ($part, %params) = @_;
927 my $bestbefore_filter = '';
928 my $bestbefore_val_cnt = 0;
929 if ($::instance_conf->get_show_bestbefore) {
930 $bestbefore_filter = ($params{bestbefore}) ? 'AND bestbefore = ?' : 'AND bestbefore IS NULL';
931 $bestbefore_val_cnt = ($params{bestbefore}) ? 1 : 0;
935 SELECT sum(qty) FROM inventory
936 WHERE parts_id = ? AND warehouse_id = ? AND bin_id = ? AND chargenumber = ? $bestbefore_filter
937 GROUP BY warehouse_id, bin_id, chargenumber
940 my @values = ($part->id,
941 $params{warehouse_id},
943 $params{chargenumber});
944 push @values, $params{bestbefore} if $bestbefore_val_cnt;
946 my ($stocked_qty) = selectrow_query($::form, $::form->get_standard_dbh, $query, @values);
948 return 1*($stocked_qty || 0);
951 sub _already_counted {
952 my ($part, %params) = @_;
954 my %bestbefore_filter;
955 if ($::instance_conf->get_show_bestbefore) {
956 %bestbefore_filter = (bestbefore => ($params{bestbefore} || undef));
959 SL::DB::Manager::Stocktaking->get_all(query => [and => [parts_id => $part->id,
960 warehouse_id => $params{warehouse_id},
961 bin_id => $params{bin_id},
962 cutoff_date => $params{cutoff_date},
963 chargenumber => $params{chargenumber},
964 %bestbefore_filter]],
965 sort_by => ['itime DESC']);
968 sub setup_stock_in_action_bar {
969 my ($self, %params) = @_;
971 for my $bar ($::request->layout->get('actionbar')) {
975 submit => [ '#form', { action => 'Inventory/stock' } ],
976 checks => [ 'check_part_selection_before_stocking' ],
977 accesskey => 'enter',
983 sub setup_stock_usage_action_bar {
984 my ($self, %params) = @_;
986 for my $bar ($::request->layout->get('actionbar')) {
990 submit => [ '#form', { action => 'Inventory/usage' } ],
991 accesskey => 'enter',
997 sub setup_stock_stocktaking_action_bar {
998 my ($self, %params) = @_;
1000 for my $bar ($::request->layout->get('actionbar')) {
1004 checks => [ 'kivi.Inventory.check_stocktaking_qty_threshold' ],
1005 call => [ 'kivi.Inventory.save_stocktaking' ],
1006 accesskey => 'enter',
1019 SL::Controller::Inventory - Controller for inventory
1023 This controller handles stock in, stocktaking and reports about inventory
1024 in warehouses/stocks
1030 - warehouse withdrawal
1036 Stocktaking allows to document the counted quantities of parts during
1037 stocktaking for a certain cutoff date. Differences between counted and stocked
1038 quantities are corrected in the stock. The transfer type 'stocktacking' is set
1041 After picking a part, the mini stock for this part is displayed. At the bottom
1042 of the form a history of already counted parts for the current employee and the
1043 choosen cutoff date is shown.
1045 Warehouse, bin and cutoff date canbe preselected in the client configuration.
1047 If a part was already counted for this cutoff date, warehouse and bin, a warning
1048 is displayed, allowing the user to choose to add the counted quantity to the
1049 stocked one or to take his counted quantity as the new stocked quantity.
1051 There is also a journal of stocktakings.
1053 Templates are located under C<templates/webpages/inventory/stocktaking>.
1054 JavaScript functions can be found in C<js/kivi.Inventory.js>.
1060 =item C<action_stock_usage>
1062 Create a search form for stock withdrawal.
1063 The search parameter for report are made like the reports in bin/mozilla/rp.pl
1065 =item C<action_usage>
1067 Make a report about stock withdrawal.
1069 The manual pagination is implemented like the pagination in SL::Controller::CsvImport.
1071 =item C<action_stocktaking>
1073 This action renders the input form for stocktaking.
1075 =item C<action_save_stocktaking>
1077 This action saves the stocktaking values and corrects the stock after checking
1078 if the part is already counted for this warehouse, bin and cutoff date.
1079 For saving SL::WH->transfer is called.
1081 =item C<action_reload_stocktaking_history>
1083 This action is responsible for displaying the stocktaking history at the bottom
1084 of the form. It uses the stocktaking journal with fixed filters for cutoff date
1085 and the current employee. The history is displayed via javascript.
1087 =item C<action_stocktaking_part_changed>
1089 This action is called after the user selected or changed the part.
1091 =item C<action_stocktaking_get_warn_qty_threshold>
1093 This action checks if a warning should be shown and returns the warning text via
1094 ajax. The warning will be shown if the given target value is greater than the
1095 threshold given in the client configuration.
1097 =item C<is_stocktaking>
1099 This is a method to check if actions are called from stocktaking form.
1100 This actions should contain "stocktaking" in their name.
1104 =head1 SPECIAL CASES
1106 Because of the PFD-Table Formatter some parameters for PDF must be different to the HTML parameters.
1107 So in german language there are some tries to use a HTML Break in the second heading line
1108 to produce two line heading inside table. The actual version has some abbreviations for the header texts.
1112 The PDF-Table library has some limits (doesn't display all if the line is to large) so
1113 the format is adapted to this
1120 =item only for C<action_stock_usage> and C<action_usage>:
1122 Martin Helmling E<lt>martin.helmling@opendynamic.deE<gt>
1124 =item for stocktaking:
1126 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>