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;
24 use English qw(-no_match_vars);
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) ],
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');
45 $::form->{title} = t8('Stock');
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 );
54 sub action_stock_usage {
57 $::form->{title} = t8('UsageE');
59 $::form->get_lists('warehouses' => { 'key' => 'WAREHOUSES',
61 $::request->layout->use_javascript("${_}.js") for qw(kivi.PartsWarehouse);
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,
77 return qw(stock incorrection found insum back outcorrection disposed
78 missing shipped used outsum consumed averconsumed);
84 $main::lxdebug->enter_sub();
86 my $form = $main::form;
87 my %myconfig = %main::myconfig;
88 my $locale = $main::locale;
90 $form->{title} = t8('UsageE');
91 $form->{report_generator_output_format} = 'HTML' if !$form->{report_generator_output_format};
93 my $report = SL::ReportGenerator->new(\%myconfig, $form);
95 my @columns = qw(partnumber partdescription);
97 push @columns , qw(ptype unit) if $form->{report_generator_output_format} eq 'HTML';
99 my @numcolumns = qw(stock incorrection found insum back outcorrection disposed
100 missing shipped used outsum consumed averconsumed);
102 push @columns , $self->getnumcolumns();
104 my @hidden_variables = qw(reporttype year duetyp fromdate todate
105 warehouse_id bin_id partnumber description bestbefore chargenumber partstypes_id);
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'), },
126 map { $column_defs{$_}->{visible} = 1 } @columns;
127 #map { $column_defs{$_}->{visible} = $form->{"l_${_}"} ? 1 : 0 } @columns;
128 map { $column_defs{$_}->{align} = 'right' } @numcolumns;
130 my @custom_headers = ();
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'},
142 map { push @line_2 , $column_defs{$_} } @columns;
143 push @custom_headers, [ @line_2 ];
145 $report->set_custom_headers(@custom_headers);
146 $report->set_columns( %column_defs );
147 $report->set_column_order(@columns);
149 $report->set_export_options('usage', @hidden_variables );
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;
161 # reporttype = custom
165 my $start = DateTime->now_local;
166 my $end = DateTime->now_local;
167 my $actualepoch = $end->epoch();
170 $searchparams{reporttype} = $form->{reporttype};
171 if ($form->{reporttype} eq "custom") {
176 #forgotten the year --> thisyear
177 if ($form->{year} !~ m/^\d\d\d\d$/) {
178 $locale->date(\%myconfig, $form->current_date(\%myconfig), 0) =~
182 my $leapday = ($form->{year} % 4 == 0) ? 1:0;
184 if ($form->{duetyp} eq "13") {
189 if ($form->{duetyp} eq "A") {
191 $days = 90 + $leapday;
193 if ($form->{duetyp} eq "B") {
199 if ($form->{duetyp} eq "C") {
205 if ($form->{duetyp} eq "D") {
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;
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;
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;
224 $mdays=$days = $eday;
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);
235 $searchparams{fromdate} = $form->{fromdate};
236 $searchparams{todate} = $form->{todate};
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});
246 $end->set_month($mm);
248 my $dur = $start->delta_md($end);
249 $days = $dur->delta_months()*30 + $dur->delta_days() ;
251 $start->set_second(0);
252 $start->set_minute(0);
254 $end->set_second(59);
255 $end->set_minute(59);
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() ;
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};
276 # alias class t2 entspricht parts
277 if ( $form->{partnumber} ) {
278 push @andfilter , ( 't2.partnumber' => { ilike => '%'. $form->{partnumber} .'%' });
279 $searchparams{partnumber} = $form->{partnumber};
281 if ( $form->{description} ) {
282 push @andfilter , ( 't2.description' => { ilike => '%'. $form->{description} .'%' });
283 $searchparams{description} = $form->{description};
285 if ( $form->{bestbefore} ) {
286 push @andfilter , ( bestbefore => { eq => $form->{bestbefore} });
287 $searchparams{bestbefore} = $form->{bestbefore};
289 if ( $form->{chargenumber} ) {
290 push @andfilter , ( chargenumber => { ilike => '%'.$form->{chargenumber}.'%' });
291 $searchparams{chargenumber} = $form->{chargenumber};
293 if ( $form->{partstypes_id} ) {
294 push @andfilter , ( 't2.partstypes_id' => $form->{partstypes_id} );
295 $searchparams{partstypes_id} = $form->{partstypes_id};
298 my @filter = (and => [ @andfilter ] );
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(...);
303 # manual paginating, yuck
304 my $page = $::form->{page} || 1;
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};
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);
325 $last_partid = $entry->parts_id;
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;
344 if ( !$allrows && $row_ind >= $last_nr ) {
348 if ( $entry->trans_type->description eq 'correction' ) {
349 $prefix = $entry->trans_type->direction;
351 $last_row->{$prefix.$entry->trans_type->description}->{data} +=
352 ( $entry->trans_type->direction eq 'out' ? -$entry->qty : $entry->qty );
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);
359 my $num_rows = @{ $report->{data} } ;
360 #$main::lxdebug->message(LXDebug->DEBUG2(), "count=".$row_ind." rows=".$num_rows);
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});
371 $report->set_options('raw_bottom_info_text' => $self->render('inventory/report_bottom', { output => 0 }) );
373 $report->generate_with_headers();
375 $main::lxdebug->leave_sub();
379 sub make_row_result {
380 my ($self,$row,$days,$partid) = @_;
381 my $form = $main::form;
382 my $myconfig = \%main::myconfig;
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;
398 my $qty = $::form->parse_amount(\%::myconfig, $::form->{qty});
400 $transfer_error = t8('Cannot stock without amount');
402 $transfer_error = t8('Cannot stock negative amounts');
405 $::form->throw_on_error(sub {
408 parts => $self->part,
409 dst_bin => $self->bin,
410 dst_wh => $self->warehouse,
413 transfer_type => 'stock',
414 chargenumber => $::form->{chargenumber},
415 bestbefore => $::form->{bestbefore},
416 ean => $::form->{ean},
417 comment => $::form->{comment},
420 } or do { $transfer_error = $EVAL_ERROR->getMessage; }
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);
431 flash_later('info', t8('Transfer successful'));
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;
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,
453 sub action_part_changed {
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');
460 $self->js->hide('#write_default_bin_span')
461 ->removeAttr('#write_default_bin', 'checked');
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')
472 sub action_warehouse_changed {
476 ->replaceWith('#bin_id', $self->build_bin_select)
481 sub action_mini_stock {
485 ->html('#stock', $self->render('inventory/_stock', { output => 0 }))
489 sub action_stocktaking {
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'));
498 sub action_save_stocktaking {
501 return $self->js->flash('error', t8('A target quantitiy has to be given'))->render()
502 if $::form->{target_qty} eq '';
504 my $target_qty = $::form->parse_amount(\%::myconfig, $::form->{target_qty});
506 return $self->js->flash('error', t8('Error: A negative target quantity is not allowed.'))->render()
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},);
515 my $stocked_qty_in_form_units = $self->part->unit_obj->convert_to($stocked_qty, $self->unit);
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',
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',
533 title => t8('Already counted'),
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';
550 $::form->throw_on_error(sub {
553 parts => $self->part,
554 $src_or_dst.'_bin' => $self->bin,
555 $src_or_dst.'_wh' => $self->warehouse,
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},
568 } or do { $transfer_error = $EVAL_ERROR->getMessage; }
571 return $self->js->flash('error', $transfer_error)->render()
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);
581 sub action_reload_stocktaking_history {
584 $::form->{filter}{'cutoff_date:date'} = $self->stocktaking_cutoff_date->to_kivitendo;
585 $::form->{filter}{'employee_id'} = SL::DB::Manager::Employee->current->id;
587 $self->prepare_stocktaking_report;
588 $self->report_generator_list_objects(report => $self->{report}, objects => $self->stocktaking_models->get, layout => 0, header => 0);
591 sub action_stocktaking_part_changed {
595 ->replaceWith('#unit_id', $self->build_unit_select)
596 ->focus('#target_qty')
600 sub action_stocktaking_journal {
603 $self->prepare_stocktaking_report(full => 1);
604 $self->report_generator_list_objects(report => $self->{report}, objects => $self->stocktaking_models->get);
607 sub action_stocktaking_get_warn_qty_threshold {
610 return $_[0]->render(\ !!0, { type => 'text' }) if $::form->{target_qty} eq '';
611 return $_[0]->render(\ !!0, { type => 'text' }) if 0 == $::instance_conf->get_stocktaking_qty_threshold;
613 my $target_qty = $::form->parse_amount(\%::myconfig, $::form->{target_qty});
614 my $stocked_qty = _get_stocked_qty($self->part,
615 warehouse_id => $self->warehouse->id,
616 bin_id => $self->bin->id,
617 chargenumber => $::form->{chargenumber},
618 bestbefore => $::form->{bestbefore},);
619 my $stocked_qty_in_form_units = $self->part->unit_obj->convert_to($stocked_qty, $self->unit);
620 my $qty = $target_qty - $stocked_qty_in_form_units;
624 if ($qty > $::instance_conf->get_stocktaking_qty_threshold) {
625 $warn = t8('The target quantity of #1 differs more than the threshold quantity of #2.',
626 $::form->{target_qty} . " " . $self->unit->name,
627 $::form->format_amount(\%::myconfig, $::instance_conf->get_stocktaking_qty_threshold, 2));
629 $warn .= t8('Choose "continue" if you want to use this value. Choose "cancel" otherwise.');
631 return $_[0]->render(\ $warn, { type => 'text' });
634 #================================================================
637 $main::auth->assert('warehouse_management');
640 sub _check_warehouses {
641 $_[0]->show_no_warehouses_error if !@{ $_[0]->warehouses };
644 sub init_warehouses {
645 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef ]]);
649 # SL::DB::Manager::Bin->get_all();
653 SL::DB::Manager::Unit->get_all;
656 sub init_is_stocktaking {
657 return $_[0]->action_name =~ m{stocktaking};
660 sub init_stocktaking_models {
663 SL::Controller::Helper::GetModels->new(
665 model => 'Stocktaking',
671 itime => t8('Insert Date'),
672 qty => t8('Target Qty'),
673 chargenumber => t8('Charge Number'),
674 comment => t8('Comment'),
675 employee => t8('Employee'),
677 partnumber => t8('Part Number'),
678 part => t8('Part Description'),
680 cutoff_date => t8('Cutoff Date'),
682 with_objects => ['employee', 'parts', 'warehouse', 'bin'],
686 sub init_stocktaking_cutoff_date {
689 return DateTime->from_kivitendo($::form->{cutoff_date_as_date}) if $::form->{cutoff_date_as_date};
690 return SL::DB::Default->get->stocktaking_cutoff_date if SL::DB::Default->get->stocktaking_cutoff_date;
692 # Default cutoff date is last day of current year, but if current month
693 # is janurary, it is the last day of the last year.
694 my $now = DateTime->now_local;
695 my $cutoff = DateTime->new(year => $now->year, month => 12, day => 31);
696 if ($now->month < 1) {
697 $cutoff->substract(years => 1);
702 sub set_target_from_part {
705 return if !$self->part;
707 $self->warehouse($self->part->warehouse) if $self->part->warehouse;
708 $self->bin( $self->part->bin) if $self->part->bin;
711 sub sanitize_target {
714 $self->warehouse($self->warehouses->[0]) if !$self->warehouse || !$self->warehouse->id;
715 $self->bin ($self->warehouse->bins->[0]) if !$self->bin || !$self->bin->id;
716 # foreach my $warehouse ( $self->warehouses ) {
717 # $warehouse->{BINS} = [];
718 # foreach my $bin ( $self->bins ) {
719 # if ( $bin->warehouse_id == $warehouse->id ) {
720 # push @{ $warehouse->{BINS} }, $bin;
726 sub load_part_from_form {
727 $_[0]->part(SL::DB::Manager::Part->find_by_or_create(id => $::form->{part_id}));
730 sub load_unit_from_form {
731 $_[0]->unit(SL::DB::Manager::Unit->find_by_or_create(id => $::form->{unit_id}));
734 sub load_wh_from_form {
736 $preselected = SL::DB::Default->get->stocktaking_warehouse_id if $_[0]->is_stocktaking;
738 $_[0]->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => ($::form->{warehouse_id} || $preselected)));
741 sub load_bin_from_form {
743 $preselected = SL::DB::Default->get->stocktaking_bin_id if $_[0]->is_stocktaking;
745 $_[0]->bin(SL::DB::Manager::Bin->find_by_or_create(id => ($::form->{bin_id} || $preselected)));
749 $::request->layout->add_javascripts('client_js.js');
752 sub build_warehouse_select {
753 select_tag('warehouse_id', $_[0]->warehouses,
754 title_key => 'description',
755 default => $_[0]->warehouse->id,
756 onchange => 'reload_bin_selection()',
760 sub build_bin_select {
761 select_tag('bin_id', [ $_[0]->warehouse->bins ],
762 title_key => 'description',
763 default => $_[0]->bin->id,
767 sub build_unit_select {
769 ? select_tag('unit_id', $_[0]->part->available_units,
771 default => $_[0]->part->unit_obj->id,
773 : select_tag('unit_id', $_[0]->units,
781 # get last 10 transaction ids
782 my $query = 'SELECT trans_id, max(itime) FROM inventory GROUP BY trans_id ORDER BY max(itime) DESC LIMIT 10';
783 my @ids = selectall_array_query($::form, $::form->get_standard_dbh, $query);
786 $objs = SL::DB::Manager::Inventory->get_all(query => [ trans_id => \@ids ]) if @ids;
788 # at most 2 of them belong to a transaction and the qty determins in or out.
789 # sort them for display
792 $transactions{ $_->trans_id }{ $_->qty > 0 ? 'in' : 'out' } = $_;
793 $transactions{ $_->trans_id }{base} = $_;
795 # and get them into order again
796 my @sorted = map { $transactions{$_} } @ids;
804 my $stock = $self->part->get_simple_stock;
805 $self->{stock_by_bin} = { map { $_->{bin_id} => $_ } @$stock };
806 $self->{stock_empty} = ! grep { $_->{sum} * 1 } @$stock;
809 sub show_no_warehouses_error {
812 my $msg = t8('No warehouse has been created yet or the quantity of the bins is not configured yet.') . ' ';
814 if ($::auth->check_right($::myconfig{login}, 'config')) { # TODO wut?
815 $msg .= t8('You can create warehouses and bins via the menu "System -> Warehouses".');
817 $msg .= t8('Please ask your administrator to create warehouses and bins.');
819 $::form->show_generic_error($msg);
822 sub prepare_stocktaking_report {
823 my ($self, %params) = @_;
825 my $callback = $self->stocktaking_models->get_callback;
827 my $report = SL::ReportGenerator->new(\%::myconfig, $::form);
828 $self->{report} = $report;
830 my @columns = qw(itime employee ean partnumber part qty unit bin chargenumber comment cutoff_date);
831 my @sortable = qw(itime employee ean partnumber part qty bin chargenumber comment cutoff_date);
834 itime => { sub => sub { $_[0]->itime_as_timestamp },
835 text => t8('Insert Date'), },
836 employee => { sub => sub { $_[0]->employee->safe_name },
837 text => t8('Employee'), },
838 ean => { sub => sub { $_[0]->part->ean },
839 text => t8('EAN'), },
840 partnumber => { sub => sub { $_[0]->part->partnumber },
841 text => t8('Part Number'), },
842 part => { sub => sub { $_[0]->part->description },
843 text => t8('Part Description'), },
844 qty => { sub => sub { $_[0]->qty_as_number },
845 text => t8('Target Qty'),
847 unit => { sub => sub { $_[0]->part->unit },
848 text => t8('Unit'), },
849 bin => { sub => sub { $_[0]->bin->full_description },
850 text => t8('Bin'), },
851 chargenumber => { text => t8('Charge Number'), },
852 comment => { text => t8('Comment'), },
853 cutoff_date => { sub => sub { $_[0]->cutoff_date_as_date },
854 text => t8('Cutoff Date'), },
857 $report->set_options(
858 std_column_visibility => 1,
859 controller_class => 'Inventory',
860 output_format => 'HTML',
861 title => (!!$params{full})? $::locale->text('Stocktaking Journal') : $::locale->text('Stocktaking History'),
862 allow_pdf_export => !!$params{full},
863 allow_csv_export => !!$params{full},
865 $report->set_columns(%column_defs);
866 $report->set_column_order(@columns);
867 $report->set_export_options(qw(stocktaking_journal filter));
868 $report->set_options_from_form;
869 $self->stocktaking_models->disable_plugin('paginated') if $report->{options}{output_format} =~ /^(pdf|csv)$/i;
870 $self->stocktaking_models->set_report_generator_sort_options(report => $report, sortable_columns => \@sortable) if !!$params{full};
871 if (!!$params{full}) {
872 $report->set_options(
873 raw_top_info_text => $self->render('inventory/stocktaking/full_report_top', { output => 0 }),
876 $report->set_options(
877 raw_bottom_info_text => $self->render('inventory/stocktaking/report_bottom', { output => 0 }),
881 sub _get_stocked_qty {
882 my ($part, %params) = @_;
884 my $bestbefore_filter = '';
885 my $bestbefore_val_cnt = 0;
886 if ($::instance_conf->get_show_bestbefore) {
887 $bestbefore_filter = ($params{bestbefore}) ? 'AND bestbefore = ?' : 'AND bestbefore IS NULL';
888 $bestbefore_val_cnt = ($params{bestbefore}) ? 1 : 0;
892 SELECT sum(qty) FROM inventory
893 WHERE parts_id = ? AND warehouse_id = ? AND bin_id = ? AND chargenumber = ? $bestbefore_filter
894 GROUP BY warehouse_id, bin_id, chargenumber
897 my @values = ($part->id,
898 $params{warehouse_id},
900 $params{chargenumber});
901 push @values, $params{bestbefore} if $bestbefore_val_cnt;
903 my ($stocked_qty) = selectrow_query($::form, $::form->get_standard_dbh, $query, @values);
905 return 1*($stocked_qty || 0);
908 sub _already_counted {
909 my ($part, %params) = @_;
911 my %bestbefore_filter;
912 if ($::instance_conf->get_show_bestbefore) {
913 %bestbefore_filter = (bestbefore => $params{bestbefore});
916 SL::DB::Manager::Stocktaking->get_all(query => [and => [parts_id => $part->id,
917 warehouse_id => $params{warehouse_id},
918 bin_id => $params{bin_id},
919 cutoff_date => $params{cutoff_date},
920 chargenumber => $params{chargenumber},
921 %bestbefore_filter]],
922 sort_by => ['itime DESC']);
925 sub setup_stock_in_action_bar {
926 my ($self, %params) = @_;
928 for my $bar ($::request->layout->get('actionbar')) {
932 submit => [ '#form', { action => 'Inventory/stock' } ],
933 checks => [ 'check_part_selection_before_stocking' ],
934 accesskey => 'enter',
940 sub setup_stock_usage_action_bar {
941 my ($self, %params) = @_;
943 for my $bar ($::request->layout->get('actionbar')) {
947 submit => [ '#form', { action => 'Inventory/usage' } ],
948 accesskey => 'enter',
954 sub setup_stock_stocktaking_action_bar {
955 my ($self, %params) = @_;
957 for my $bar ($::request->layout->get('actionbar')) {
961 checks => [ 'kivi.Inventory.check_stocktaking_qty_threshold' ],
962 call => [ 'kivi.Inventory.save_stocktaking' ],
963 accesskey => 'enter',
976 SL::Controller::Inventory - Controller for inventory
980 This controller handles stock in, stocktaking and reports about inventory
987 - warehouse withdrawal
993 Stocktaking allows to document the counted quantities of parts during
994 stocktaking for a certain cutoff date. Differences between counted and stocked
995 quantities are corrected in the stock. The transfer type 'stocktacking' is set
998 After picking a part, the mini stock for this part is displayed. At the bottom
999 of the form a history of already counted parts for the current employee and the
1000 choosen cutoff date is shown.
1002 Warehouse, bin and cutoff date canbe preselected in the client configuration.
1004 If a part was already counted for this cutoff date, warehouse and bin, a warning
1005 is displayed, allowing the user to choose to add the counted quantity to the
1006 stocked one or to take his counted quantity as the new stocked quantity.
1008 There is also a journal of stocktakings.
1010 Templates are located under C<templates/webpages/inventory/stocktaking>.
1011 JavaScript functions can be found in C<js/kivi.Inventory.js>.
1017 =item C<action_stock_usage>
1019 Create a search form for stock withdrawal.
1020 The search parameter for report are made like the reports in bin/mozilla/rp.pl
1022 =item C<action_usage>
1024 Make a report about stock withdrawal.
1026 The manual pagination is implemented like the pagination in SL::Controller::CsvImport.
1028 =item C<action_stocktaking>
1030 This action renders the input form for stocktaking.
1032 =item C<action_save_stocktaking>
1034 This action saves the stocktaking values and corrects the stock after checking
1035 if the part is already counted for this warehouse, bin and cutoff date.
1036 For saving SL::WH->transfer is called.
1038 =item C<action_reload_stocktaking_history>
1040 This action is responsible for displaying the stocktaking history at the bottom
1041 of the form. It uses the stocktaking journal with fixed filters for cutoff date
1042 and the current employee. The history is displayed via javascript.
1044 =item C<action_stocktaking_part_changed>
1046 This action is called after the user selected or changed the part.
1048 =item C<action_stocktaking_get_warn_qty_threshold>
1050 This action checks if a warning should be shown and returns the warning text via
1051 ajax. The warning will be shown if the given target value is greater than the
1052 threshold given in the client configuration.
1054 =item C<is_stocktaking>
1056 This is a method to check if actions are called from stocktaking form.
1057 This actions should contain "stocktaking" in their name.
1061 =head1 SPECIAL CASES
1063 Because of the PFD-Table Formatter some parameters for PDF must be different to the HTML parameters.
1064 So in german language there are some tries to use a HTML Break in the second heading line
1065 to produce two line heading inside table. The actual version has some abbreviations for the header texts.
1069 The PDF-Table library has some limits (doesn't display all if the line is to large) so
1070 the format is adapted to this
1077 =item only for C<action_stock_usage> and C<action_usage>:
1079 Martin Helmling E<lt>martin.helmling@opendynamic.deE<gt>
1081 =item for stocktaking:
1083 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>