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',
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,
76 return qw(stock incorrection found insum back outcorrection disposed
77 missing shipped used outsum consumed averconsumed);
83 $main::lxdebug->enter_sub();
85 my $form = $main::form;
86 my %myconfig = %main::myconfig;
87 my $locale = $main::locale;
89 $form->{title} = t8('UsageE');
90 $form->{report_generator_output_format} = 'HTML' if !$form->{report_generator_output_format};
92 my $report = SL::ReportGenerator->new(\%myconfig, $form);
94 my @columns = qw(partnumber partdescription);
96 push @columns , qw(ptype unit) if $form->{report_generator_output_format} eq 'HTML';
98 my @numcolumns = qw(stock incorrection found insum back outcorrection disposed
99 missing shipped used outsum consumed averconsumed);
101 push @columns , $self->getnumcolumns();
103 my @hidden_variables = qw(reporttype year duetyp fromdate todate
104 warehouse_id bin_id partnumber description bestbefore chargenumber partstypes_id);
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'), },
125 map { $column_defs{$_}->{visible} = 1 } @columns;
126 #map { $column_defs{$_}->{visible} = $form->{"l_${_}"} ? 1 : 0 } @columns;
127 map { $column_defs{$_}->{align} = 'right' } @numcolumns;
129 my @custom_headers = ();
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'},
141 map { push @line_2 , $column_defs{$_} } @columns;
142 push @custom_headers, [ @line_2 ];
144 $report->set_custom_headers(@custom_headers);
145 $report->set_columns( %column_defs );
146 $report->set_column_order(@columns);
148 $report->set_export_options('usage', @hidden_variables );
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;
160 # reporttype = custom
164 my $start = DateTime->now_local;
165 my $end = DateTime->now_local;
166 my $actualepoch = $end->epoch();
169 $searchparams{reporttype} = $form->{reporttype};
170 if ($form->{reporttype} eq "custom") {
175 #forgotten the year --> thisyear
176 if ($form->{year} !~ m/^\d\d\d\d$/) {
177 $locale->date(\%myconfig, $form->current_date(\%myconfig), 0) =~
181 my $leapday = ($form->{year} % 4 == 0) ? 1:0;
183 if ($form->{duetyp} eq "13") {
188 if ($form->{duetyp} eq "A") {
190 $days = 90 + $leapday;
192 if ($form->{duetyp} eq "B") {
198 if ($form->{duetyp} eq "C") {
204 if ($form->{duetyp} eq "D") {
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;
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;
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;
223 $mdays=$days = $eday;
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);
234 $searchparams{fromdate} = $form->{fromdate};
235 $searchparams{todate} = $form->{todate};
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});
245 $end->set_month($mm);
247 my $dur = $start->delta_md($end);
248 $days = $dur->delta_months()*30 + $dur->delta_days() ;
250 $start->set_second(0);
251 $start->set_minute(0);
253 $end->set_second(59);
254 $end->set_minute(59);
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() ;
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};
275 # alias class t2 entspricht parts
276 if ( $form->{partnumber} ) {
277 push @andfilter , ( 't2.partnumber' => { ilike => '%'. $form->{partnumber} .'%' });
278 $searchparams{partnumber} = $form->{partnumber};
280 if ( $form->{description} ) {
281 push @andfilter , ( 't2.description' => { ilike => '%'. $form->{description} .'%' });
282 $searchparams{description} = $form->{description};
284 if ( $form->{bestbefore} ) {
285 push @andfilter , ( bestbefore => { eq => $form->{bestbefore} });
286 $searchparams{bestbefore} = $form->{bestbefore};
288 if ( $form->{chargenumber} ) {
289 push @andfilter , ( chargenumber => { ilike => '%'.$form->{chargenumber}.'%' });
290 $searchparams{chargenumber} = $form->{chargenumber};
292 if ( $form->{partstypes_id} ) {
293 push @andfilter , ( 't2.partstypes_id' => $form->{partstypes_id} );
294 $searchparams{partstypes_id} = $form->{partstypes_id};
297 my @filter = (and => [ @andfilter ] );
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(...);
302 # manual paginating, yuck
303 my $page = $::form->{page} || 1;
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};
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);
324 $last_partid = $entry->parts_id;
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;
343 if ( !$allrows && $row_ind >= $last_nr ) {
347 if ( $entry->trans_type->description eq 'correction' ) {
348 $prefix = $entry->trans_type->direction;
350 $last_row->{$prefix.$entry->trans_type->description}->{data} +=
351 ( $entry->trans_type->direction eq 'out' ? -$entry->qty : $entry->qty );
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);
358 my $num_rows = @{ $report->{data} } ;
359 #$main::lxdebug->message(LXDebug->DEBUG2(), "count=".$row_ind." rows=".$num_rows);
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});
370 $report->set_options('raw_bottom_info_text' => $self->render('inventory/report_bottom', { output => 0 }) );
372 $report->generate_with_headers();
374 $main::lxdebug->leave_sub();
378 sub make_row_result {
379 my ($self,$row,$days,$partid) = @_;
380 my $form = $main::form;
381 my $myconfig = \%main::myconfig;
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;
397 my $qty = $::form->parse_amount(\%::myconfig, $::form->{qty});
399 $transfer_error = t8('Cannot stock without amount');
401 $transfer_error = t8('Cannot stock negative amounts');
404 $::form->throw_on_error(sub {
407 parts => $self->part,
408 dst_bin => $self->bin,
409 dst_wh => $self->warehouse,
412 transfer_type => 'stock',
413 transfer_type_id => $::form->{transfer_type_id},
414 chargenumber => $::form->{chargenumber},
415 bestbefore => $::form->{bestbefore},
416 comment => $::form->{comment},
419 } or do { $transfer_error = $EVAL_ERROR->error; }
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);
430 flash_later('info', t8('Transfer successful'));
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;
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,
452 sub action_part_changed {
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');
459 $self->js->hide('#write_default_bin_span')
460 ->removeAttr('#write_default_bin', 'checked');
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')
471 sub action_warehouse_changed {
475 ->replaceWith('#bin_id', $self->build_bin_select)
480 sub action_mini_stock {
484 ->html('#stock', $self->render('inventory/_stock', { output => 0 }))
488 sub action_stocktaking {
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'));
497 sub action_save_stocktaking {
500 return $self->js->flash('error', t8('Please choose a part.'))->render()
501 if !$::form->{part_id};
503 return $self->js->flash('error', t8('A target quantitiy has to be given'))->render()
504 if $::form->{target_qty} eq '';
506 my $target_qty = $::form->parse_amount(\%::myconfig, $::form->{target_qty});
508 return $self->js->flash('error', t8('Error: A negative target quantity is not allowed.'))->render()
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},);
517 my $stocked_qty_in_form_units = $self->part->unit_obj->convert_to($stocked_qty, $self->unit);
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',
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',
535 title => t8('Already counted'),
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';
552 $::form->throw_on_error(sub {
555 parts => $self->part,
556 $src_or_dst.'_bin' => $self->bin,
557 $src_or_dst.'_wh' => $self->warehouse,
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},
570 } or do { $transfer_error = $EVAL_ERROR->error; }
573 return $self->js->flash('error', $transfer_error)->render()
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);
583 sub action_reload_stocktaking_history {
586 $::form->{filter}{'cutoff_date:date'} = $self->stocktaking_cutoff_date->to_kivitendo;
587 $::form->{filter}{'employee_id'} = SL::DB::Manager::Employee->current->id;
589 $self->prepare_stocktaking_report;
590 $self->report_generator_list_objects(report => $self->{report}, objects => $self->stocktaking_models->get, layout => 0, header => 0);
593 sub action_stocktaking_part_changed {
597 ->replaceWith('#unit_id', $self->build_unit_select)
598 ->focus('#target_qty')
602 sub action_stocktaking_journal {
605 $self->prepare_stocktaking_report(full => 1);
606 $self->report_generator_list_objects(report => $self->{report}, objects => $self->stocktaking_models->get);
609 sub action_stocktaking_get_warn_qty_threshold {
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;
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;
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));
632 $warn .= t8('Choose "continue" if you want to use this value. Choose "cancel" otherwise.');
634 return $_[0]->render(\ $warn, { type => 'text' });
637 #================================================================
640 $main::auth->assert('warehouse_management');
643 sub _check_warehouses {
644 $_[0]->show_no_warehouses_error if !@{ $_[0]->warehouses };
647 sub init_warehouses {
648 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef ]]);
652 # SL::DB::Manager::Bin->get_all();
656 SL::DB::Manager::Unit->get_all;
659 sub init_is_stocktaking {
660 return $_[0]->action_name =~ m{stocktaking};
663 sub init_stocktaking_models {
666 SL::Controller::Helper::GetModels->new(
668 model => 'Stocktaking',
674 itime => t8('Insert Date'),
675 qty => t8('Target Qty'),
676 chargenumber => t8('Charge Number'),
677 comment => t8('Comment'),
678 employee => t8('Employee'),
680 partnumber => t8('Part Number'),
681 part => t8('Part Description'),
683 cutoff_date => t8('Cutoff Date'),
685 with_objects => ['employee', 'parts', 'warehouse', 'bin'],
689 sub init_stocktaking_cutoff_date {
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;
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);
705 sub set_target_from_part {
708 return if !$self->part;
710 $self->warehouse($self->part->warehouse) if $self->part->warehouse;
711 $self->bin( $self->part->bin) if $self->part->bin;
714 sub sanitize_target {
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;
729 sub load_part_from_form {
730 $_[0]->part(SL::DB::Manager::Part->find_by_or_create(id => $::form->{part_id}||undef));
733 sub load_unit_from_form {
734 $_[0]->unit(SL::DB::Manager::Unit->find_by_or_create(id => $::form->{unit_id}));
737 sub load_wh_from_form {
739 $preselected = SL::DB::Default->get->stocktaking_warehouse_id if $_[0]->is_stocktaking;
741 $_[0]->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => ($::form->{warehouse_id} || $preselected)));
744 sub load_bin_from_form {
746 $preselected = SL::DB::Default->get->stocktaking_bin_id if $_[0]->is_stocktaking;
748 $_[0]->bin(SL::DB::Manager::Bin->find_by_or_create(id => ($::form->{bin_id} || $preselected)));
752 $::request->layout->add_javascripts('client_js.js');
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()',
763 sub build_bin_select {
764 select_tag('bin_id', [ $_[0]->warehouse->bins ],
765 title_key => 'description',
766 default => $_[0]->bin->id,
770 sub build_unit_select {
772 ? select_tag('unit_id', $_[0]->part->available_units,
774 default => $_[0]->part->unit_obj->id,
776 : select_tag('unit_id', $_[0]->units,
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);
789 $objs = SL::DB::Manager::Inventory->get_all(query => [ trans_id => \@ids ]) if @ids;
791 # at most 2 of them belong to a transaction and the qty determins in or out.
792 # sort them for display
795 $transactions{ $_->trans_id }{ $_->qty > 0 ? 'in' : 'out' } = $_;
796 $transactions{ $_->trans_id }{base} = $_;
798 # and get them into order again
799 my @sorted = map { $transactions{$_} } @ids;
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;
812 sub show_no_warehouses_error {
815 my $msg = t8('No warehouse has been created yet or the quantity of the bins is not configured yet.') . ' ';
817 if ($::auth->check_right($::myconfig{login}, 'config')) { # TODO wut?
818 $msg .= t8('You can create warehouses and bins via the menu "System -> Warehouses".');
820 $msg .= t8('Please ask your administrator to create warehouses and bins.');
822 $::form->show_generic_error($msg);
825 sub prepare_stocktaking_report {
826 my ($self, %params) = @_;
828 my $callback = $self->stocktaking_models->get_callback;
830 my $report = SL::ReportGenerator->new(\%::myconfig, $::form);
831 $self->{report} = $report;
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);
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'),
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'), },
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},
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 }),
879 $report->set_options(
880 raw_bottom_info_text => $self->render('inventory/stocktaking/report_bottom', { output => 0 }),
884 sub _get_stocked_qty {
885 my ($part, %params) = @_;
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;
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
900 my @values = ($part->id,
901 $params{warehouse_id},
903 $params{chargenumber});
904 push @values, $params{bestbefore} if $bestbefore_val_cnt;
906 my ($stocked_qty) = selectrow_query($::form, $::form->get_standard_dbh, $query, @values);
908 return 1*($stocked_qty || 0);
911 sub _already_counted {
912 my ($part, %params) = @_;
914 my %bestbefore_filter;
915 if ($::instance_conf->get_show_bestbefore) {
916 %bestbefore_filter = (bestbefore => $params{bestbefore});
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']);
928 sub setup_stock_in_action_bar {
929 my ($self, %params) = @_;
931 for my $bar ($::request->layout->get('actionbar')) {
935 submit => [ '#form', { action => 'Inventory/stock' } ],
936 checks => [ 'check_part_selection_before_stocking' ],
937 accesskey => 'enter',
943 sub setup_stock_usage_action_bar {
944 my ($self, %params) = @_;
946 for my $bar ($::request->layout->get('actionbar')) {
950 submit => [ '#form', { action => 'Inventory/usage' } ],
951 accesskey => 'enter',
957 sub setup_stock_stocktaking_action_bar {
958 my ($self, %params) = @_;
960 for my $bar ($::request->layout->get('actionbar')) {
964 checks => [ 'kivi.Inventory.check_stocktaking_qty_threshold' ],
965 call => [ 'kivi.Inventory.save_stocktaking' ],
966 accesskey => 'enter',
979 SL::Controller::Inventory - Controller for inventory
983 This controller handles stock in, stocktaking and reports about inventory
990 - warehouse withdrawal
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
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.
1005 Warehouse, bin and cutoff date canbe preselected in the client configuration.
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.
1011 There is also a journal of stocktakings.
1013 Templates are located under C<templates/webpages/inventory/stocktaking>.
1014 JavaScript functions can be found in C<js/kivi.Inventory.js>.
1020 =item C<action_stock_usage>
1022 Create a search form for stock withdrawal.
1023 The search parameter for report are made like the reports in bin/mozilla/rp.pl
1025 =item C<action_usage>
1027 Make a report about stock withdrawal.
1029 The manual pagination is implemented like the pagination in SL::Controller::CsvImport.
1031 =item C<action_stocktaking>
1033 This action renders the input form for stocktaking.
1035 =item C<action_save_stocktaking>
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.
1041 =item C<action_reload_stocktaking_history>
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.
1047 =item C<action_stocktaking_part_changed>
1049 This action is called after the user selected or changed the part.
1051 =item C<action_stocktaking_get_warn_qty_threshold>
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.
1057 =item C<is_stocktaking>
1059 This is a method to check if actions are called from stocktaking form.
1060 This actions should contain "stocktaking" in their name.
1064 =head1 SPECIAL CASES
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.
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
1080 =item only for C<action_stock_usage> and C<action_usage>:
1082 Martin Helmling E<lt>martin.helmling@opendynamic.deE<gt>
1084 =item for stocktaking:
1086 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>