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 transfer_type_id => $::form->{transfer_type_id},
415 chargenumber => $::form->{chargenumber},
416 bestbefore => $::form->{bestbefore},
417 ean => $::form->{ean},
418 comment => $::form->{comment},
421 } or do { $transfer_error = $EVAL_ERROR->getMessage; }
424 if (!$transfer_error) {
425 if ($::form->{write_default_bin}) {
426 $self->part->load; # onhand is calculated in between. don't mess that up
427 $self->part->bin($self->bin);
428 $self->part->warehouse($self->warehouse);
432 flash_later('info', t8('Transfer successful'));
436 my %additional_redirect_params = ();
437 if ($transfer_error) {
438 flash_later('error', $transfer_error);
439 $additional_redirect_params{$_} = $::form->{$_} for qw(qty chargenumber bestbefore ean comment);
440 $additional_redirect_params{qty} = $qty;
445 action => 'stock_in',
446 part_id => $self->part->id,
447 bin_id => $self->bin->id,
448 warehouse_id => $self->warehouse->id,
449 unit_id => $self->unit->id,
450 %additional_redirect_params,
454 sub action_part_changed {
457 # no standard? ask user if he wants to write it
458 if ($self->part->id && !$self->part->bin_id && !$self->part->warehouse_id) {
459 $self->js->show('#write_default_bin_span');
461 $self->js->hide('#write_default_bin_span')
462 ->removeAttr('#write_default_bin', 'checked');
466 ->replaceWith('#warehouse_id', $self->build_warehouse_select)
467 ->replaceWith('#bin_id', $self->build_bin_select)
468 ->replaceWith('#unit_id', $self->build_unit_select)
469 ->focus('#warehouse_id')
473 sub action_warehouse_changed {
477 ->replaceWith('#bin_id', $self->build_bin_select)
482 sub action_mini_stock {
486 ->html('#stock', $self->render('inventory/_stock', { output => 0 }))
490 sub action_stocktaking {
493 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.Inventory);
494 $::request->layout->focus('#part_id_name');
495 $self->setup_stock_stocktaking_action_bar;
496 $self->render('inventory/stocktaking/form', title => t8('Stocktaking'));
499 sub action_save_stocktaking {
502 return $self->js->flash('error', t8('Please choose a part.'))->render()
503 if !$::form->{part_id};
505 return $self->js->flash('error', t8('A target quantitiy has to be given'))->render()
506 if $::form->{target_qty} eq '';
508 my $target_qty = $::form->parse_amount(\%::myconfig, $::form->{target_qty});
510 return $self->js->flash('error', t8('Error: A negative target quantity is not allowed.'))->render()
513 my $stocked_qty = _get_stocked_qty($self->part,
514 warehouse_id => $self->warehouse->id,
515 bin_id => $self->bin->id,
516 chargenumber => $::form->{chargenumber},
517 bestbefore => $::form->{bestbefore},);
519 my $stocked_qty_in_form_units = $self->part->unit_obj->convert_to($stocked_qty, $self->unit);
521 if (!$::form->{dont_check_already_counted}) {
522 my $already_counted = _already_counted($self->part,
523 warehouse_id => $self->warehouse->id,
524 bin_id => $self->bin->id,
525 cutoff_date => $::form->{cutoff_date_as_date},
526 chargenumber => $::form->{chargenumber},
527 bestbefore => $::form->{bestbefore});
528 if (scalar @$already_counted) {
529 my $reply = $self->js->dialog->open({
530 html => $self->render('inventory/stocktaking/_already_counted_dialog',
532 already_counted => $already_counted,
533 stocked_qty => $stocked_qty,
534 stocked_qty_in_form_units => $stocked_qty_in_form_units),
535 id => 'already_counted_dialog',
537 title => t8('Already counted'),
545 # - target_qty is in units given in form ($self->unit)
546 # - WH->transfer expects qtys in given unit (here: unit from form (unit -> $self->unit))
547 # Therefore use stocked_qty in form units for calculation.
548 my $qty = $target_qty - $stocked_qty_in_form_units;
549 my $src_or_dst = $qty < 0? 'src' : 'dst';
554 $::form->throw_on_error(sub {
557 parts => $self->part,
558 $src_or_dst.'_bin' => $self->bin,
559 $src_or_dst.'_wh' => $self->warehouse,
562 transfer_type => 'stocktaking',
563 chargenumber => $::form->{chargenumber},
564 bestbefore => $::form->{bestbefore},
565 ean => $::form->{ean},
566 comment => $::form->{comment},
567 record_stocktaking => 1,
568 stocktaking_qty => $target_qty,
569 stocktaking_cutoff_date => $::form->{cutoff_date_as_date},
572 } or do { $transfer_error = $EVAL_ERROR->getMessage; }
575 return $self->js->flash('error', $transfer_error)->render()
578 flash_later('info', $::locale->text('Part successful counted'));
579 $self->redirect_to(action => 'stocktaking',
580 warehouse_id => $self->warehouse->id,
581 bin_id => $self->bin->id,
582 cutoff_date_as_date => $self->stocktaking_cutoff_date->to_kivitendo);
585 sub action_reload_stocktaking_history {
588 $::form->{filter}{'cutoff_date:date'} = $self->stocktaking_cutoff_date->to_kivitendo;
589 $::form->{filter}{'employee_id'} = SL::DB::Manager::Employee->current->id;
591 $self->prepare_stocktaking_report;
592 $self->report_generator_list_objects(report => $self->{report}, objects => $self->stocktaking_models->get, layout => 0, header => 0);
595 sub action_stocktaking_part_changed {
599 ->replaceWith('#unit_id', $self->build_unit_select)
600 ->focus('#target_qty')
604 sub action_stocktaking_journal {
607 $self->prepare_stocktaking_report(full => 1);
608 $self->report_generator_list_objects(report => $self->{report}, objects => $self->stocktaking_models->get);
611 sub action_stocktaking_get_warn_qty_threshold {
614 return $_[0]->render(\ !!0, { type => 'text' }) if !$::form->{part_id};
615 return $_[0]->render(\ !!0, { type => 'text' }) if $::form->{target_qty} eq '';
616 return $_[0]->render(\ !!0, { type => 'text' }) if 0 == $::instance_conf->get_stocktaking_qty_threshold;
618 my $target_qty = $::form->parse_amount(\%::myconfig, $::form->{target_qty});
619 my $stocked_qty = _get_stocked_qty($self->part,
620 warehouse_id => $self->warehouse->id,
621 bin_id => $self->bin->id,
622 chargenumber => $::form->{chargenumber},
623 bestbefore => $::form->{bestbefore},);
624 my $stocked_qty_in_form_units = $self->part->unit_obj->convert_to($stocked_qty, $self->unit);
625 my $qty = $target_qty - $stocked_qty_in_form_units;
629 if ($qty > $::instance_conf->get_stocktaking_qty_threshold) {
630 $warn = t8('The target quantity of #1 differs more than the threshold quantity of #2.',
631 $::form->{target_qty} . " " . $self->unit->name,
632 $::form->format_amount(\%::myconfig, $::instance_conf->get_stocktaking_qty_threshold, 2));
634 $warn .= t8('Choose "continue" if you want to use this value. Choose "cancel" otherwise.');
636 return $_[0]->render(\ $warn, { type => 'text' });
639 #================================================================
642 $main::auth->assert('warehouse_management');
645 sub _check_warehouses {
646 $_[0]->show_no_warehouses_error if !@{ $_[0]->warehouses };
649 sub init_warehouses {
650 SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef ]]);
654 # SL::DB::Manager::Bin->get_all();
658 SL::DB::Manager::Unit->get_all;
661 sub init_is_stocktaking {
662 return $_[0]->action_name =~ m{stocktaking};
665 sub init_stocktaking_models {
668 SL::Controller::Helper::GetModels->new(
670 model => 'Stocktaking',
676 itime => t8('Insert Date'),
677 qty => t8('Target Qty'),
678 chargenumber => t8('Charge Number'),
679 comment => t8('Comment'),
680 employee => t8('Employee'),
682 partnumber => t8('Part Number'),
683 part => t8('Part Description'),
685 cutoff_date => t8('Cutoff Date'),
687 with_objects => ['employee', 'parts', 'warehouse', 'bin'],
691 sub init_stocktaking_cutoff_date {
694 return DateTime->from_kivitendo($::form->{cutoff_date_as_date}) if $::form->{cutoff_date_as_date};
695 return SL::DB::Default->get->stocktaking_cutoff_date if SL::DB::Default->get->stocktaking_cutoff_date;
697 # Default cutoff date is last day of current year, but if current month
698 # is janurary, it is the last day of the last year.
699 my $now = DateTime->now_local;
700 my $cutoff = DateTime->new(year => $now->year, month => 12, day => 31);
701 if ($now->month < 1) {
702 $cutoff->substract(years => 1);
707 sub set_target_from_part {
710 return if !$self->part;
712 $self->warehouse($self->part->warehouse) if $self->part->warehouse;
713 $self->bin( $self->part->bin) if $self->part->bin;
716 sub sanitize_target {
719 $self->warehouse($self->warehouses->[0]) if !$self->warehouse || !$self->warehouse->id;
720 $self->bin ($self->warehouse->bins->[0]) if !$self->bin || !$self->bin->id;
721 # foreach my $warehouse ( $self->warehouses ) {
722 # $warehouse->{BINS} = [];
723 # foreach my $bin ( $self->bins ) {
724 # if ( $bin->warehouse_id == $warehouse->id ) {
725 # push @{ $warehouse->{BINS} }, $bin;
731 sub load_part_from_form {
732 $_[0]->part(SL::DB::Manager::Part->find_by_or_create(id => $::form->{part_id}||undef));
735 sub load_unit_from_form {
736 $_[0]->unit(SL::DB::Manager::Unit->find_by_or_create(id => $::form->{unit_id}));
739 sub load_wh_from_form {
741 $preselected = SL::DB::Default->get->stocktaking_warehouse_id if $_[0]->is_stocktaking;
743 $_[0]->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => ($::form->{warehouse_id} || $preselected)));
746 sub load_bin_from_form {
748 $preselected = SL::DB::Default->get->stocktaking_bin_id if $_[0]->is_stocktaking;
750 $_[0]->bin(SL::DB::Manager::Bin->find_by_or_create(id => ($::form->{bin_id} || $preselected)));
754 $::request->layout->add_javascripts('client_js.js');
757 sub build_warehouse_select {
758 select_tag('warehouse_id', $_[0]->warehouses,
759 title_key => 'description',
760 default => $_[0]->warehouse->id,
761 onchange => 'reload_bin_selection()',
765 sub build_bin_select {
766 select_tag('bin_id', [ $_[0]->warehouse->bins ],
767 title_key => 'description',
768 default => $_[0]->bin->id,
772 sub build_unit_select {
774 ? select_tag('unit_id', $_[0]->part->available_units,
776 default => $_[0]->part->unit_obj->id,
778 : select_tag('unit_id', $_[0]->units,
786 # get last 10 transaction ids
787 my $query = 'SELECT trans_id, max(itime) FROM inventory GROUP BY trans_id ORDER BY max(itime) DESC LIMIT 10';
788 my @ids = selectall_array_query($::form, $::form->get_standard_dbh, $query);
791 $objs = SL::DB::Manager::Inventory->get_all(query => [ trans_id => \@ids ]) if @ids;
793 # at most 2 of them belong to a transaction and the qty determins in or out.
794 # sort them for display
797 $transactions{ $_->trans_id }{ $_->qty > 0 ? 'in' : 'out' } = $_;
798 $transactions{ $_->trans_id }{base} = $_;
800 # and get them into order again
801 my @sorted = map { $transactions{$_} } @ids;
809 my $stock = $self->part->get_simple_stock;
810 $self->{stock_by_bin} = { map { $_->{bin_id} => $_ } @$stock };
811 $self->{stock_empty} = ! grep { $_->{sum} * 1 } @$stock;
814 sub show_no_warehouses_error {
817 my $msg = t8('No warehouse has been created yet or the quantity of the bins is not configured yet.') . ' ';
819 if ($::auth->check_right($::myconfig{login}, 'config')) { # TODO wut?
820 $msg .= t8('You can create warehouses and bins via the menu "System -> Warehouses".');
822 $msg .= t8('Please ask your administrator to create warehouses and bins.');
824 $::form->show_generic_error($msg);
827 sub prepare_stocktaking_report {
828 my ($self, %params) = @_;
830 my $callback = $self->stocktaking_models->get_callback;
832 my $report = SL::ReportGenerator->new(\%::myconfig, $::form);
833 $self->{report} = $report;
835 my @columns = qw(itime employee ean partnumber part qty unit bin chargenumber comment cutoff_date);
836 my @sortable = qw(itime employee ean partnumber part qty bin chargenumber comment cutoff_date);
839 itime => { sub => sub { $_[0]->itime_as_timestamp },
840 text => t8('Insert Date'), },
841 employee => { sub => sub { $_[0]->employee->safe_name },
842 text => t8('Employee'), },
843 ean => { sub => sub { $_[0]->part->ean },
844 text => t8('EAN'), },
845 partnumber => { sub => sub { $_[0]->part->partnumber },
846 text => t8('Part Number'), },
847 part => { sub => sub { $_[0]->part->description },
848 text => t8('Part Description'), },
849 qty => { sub => sub { $_[0]->qty_as_number },
850 text => t8('Target Qty'),
852 unit => { sub => sub { $_[0]->part->unit },
853 text => t8('Unit'), },
854 bin => { sub => sub { $_[0]->bin->full_description },
855 text => t8('Bin'), },
856 chargenumber => { text => t8('Charge Number'), },
857 comment => { text => t8('Comment'), },
858 cutoff_date => { sub => sub { $_[0]->cutoff_date_as_date },
859 text => t8('Cutoff Date'), },
862 $report->set_options(
863 std_column_visibility => 1,
864 controller_class => 'Inventory',
865 output_format => 'HTML',
866 title => (!!$params{full})? $::locale->text('Stocktaking Journal') : $::locale->text('Stocktaking History'),
867 allow_pdf_export => !!$params{full},
868 allow_csv_export => !!$params{full},
870 $report->set_columns(%column_defs);
871 $report->set_column_order(@columns);
872 $report->set_export_options(qw(stocktaking_journal filter));
873 $report->set_options_from_form;
874 $self->stocktaking_models->disable_plugin('paginated') if $report->{options}{output_format} =~ /^(pdf|csv)$/i;
875 $self->stocktaking_models->set_report_generator_sort_options(report => $report, sortable_columns => \@sortable) if !!$params{full};
876 if (!!$params{full}) {
877 $report->set_options(
878 raw_top_info_text => $self->render('inventory/stocktaking/full_report_top', { output => 0 }),
881 $report->set_options(
882 raw_bottom_info_text => $self->render('inventory/stocktaking/report_bottom', { output => 0 }),
886 sub _get_stocked_qty {
887 my ($part, %params) = @_;
889 my $bestbefore_filter = '';
890 my $bestbefore_val_cnt = 0;
891 if ($::instance_conf->get_show_bestbefore) {
892 $bestbefore_filter = ($params{bestbefore}) ? 'AND bestbefore = ?' : 'AND bestbefore IS NULL';
893 $bestbefore_val_cnt = ($params{bestbefore}) ? 1 : 0;
897 SELECT sum(qty) FROM inventory
898 WHERE parts_id = ? AND warehouse_id = ? AND bin_id = ? AND chargenumber = ? $bestbefore_filter
899 GROUP BY warehouse_id, bin_id, chargenumber
902 my @values = ($part->id,
903 $params{warehouse_id},
905 $params{chargenumber});
906 push @values, $params{bestbefore} if $bestbefore_val_cnt;
908 my ($stocked_qty) = selectrow_query($::form, $::form->get_standard_dbh, $query, @values);
910 return 1*($stocked_qty || 0);
913 sub _already_counted {
914 my ($part, %params) = @_;
916 my %bestbefore_filter;
917 if ($::instance_conf->get_show_bestbefore) {
918 %bestbefore_filter = (bestbefore => $params{bestbefore});
921 SL::DB::Manager::Stocktaking->get_all(query => [and => [parts_id => $part->id,
922 warehouse_id => $params{warehouse_id},
923 bin_id => $params{bin_id},
924 cutoff_date => $params{cutoff_date},
925 chargenumber => $params{chargenumber},
926 %bestbefore_filter]],
927 sort_by => ['itime DESC']);
930 sub setup_stock_in_action_bar {
931 my ($self, %params) = @_;
933 for my $bar ($::request->layout->get('actionbar')) {
937 submit => [ '#form', { action => 'Inventory/stock' } ],
938 checks => [ 'check_part_selection_before_stocking' ],
939 accesskey => 'enter',
945 sub setup_stock_usage_action_bar {
946 my ($self, %params) = @_;
948 for my $bar ($::request->layout->get('actionbar')) {
952 submit => [ '#form', { action => 'Inventory/usage' } ],
953 accesskey => 'enter',
959 sub setup_stock_stocktaking_action_bar {
960 my ($self, %params) = @_;
962 for my $bar ($::request->layout->get('actionbar')) {
966 checks => [ 'kivi.Inventory.check_stocktaking_qty_threshold' ],
967 call => [ 'kivi.Inventory.save_stocktaking' ],
968 accesskey => 'enter',
981 SL::Controller::Inventory - Controller for inventory
985 This controller handles stock in, stocktaking and reports about inventory
992 - warehouse withdrawal
998 Stocktaking allows to document the counted quantities of parts during
999 stocktaking for a certain cutoff date. Differences between counted and stocked
1000 quantities are corrected in the stock. The transfer type 'stocktacking' is set
1003 After picking a part, the mini stock for this part is displayed. At the bottom
1004 of the form a history of already counted parts for the current employee and the
1005 choosen cutoff date is shown.
1007 Warehouse, bin and cutoff date canbe preselected in the client configuration.
1009 If a part was already counted for this cutoff date, warehouse and bin, a warning
1010 is displayed, allowing the user to choose to add the counted quantity to the
1011 stocked one or to take his counted quantity as the new stocked quantity.
1013 There is also a journal of stocktakings.
1015 Templates are located under C<templates/webpages/inventory/stocktaking>.
1016 JavaScript functions can be found in C<js/kivi.Inventory.js>.
1022 =item C<action_stock_usage>
1024 Create a search form for stock withdrawal.
1025 The search parameter for report are made like the reports in bin/mozilla/rp.pl
1027 =item C<action_usage>
1029 Make a report about stock withdrawal.
1031 The manual pagination is implemented like the pagination in SL::Controller::CsvImport.
1033 =item C<action_stocktaking>
1035 This action renders the input form for stocktaking.
1037 =item C<action_save_stocktaking>
1039 This action saves the stocktaking values and corrects the stock after checking
1040 if the part is already counted for this warehouse, bin and cutoff date.
1041 For saving SL::WH->transfer is called.
1043 =item C<action_reload_stocktaking_history>
1045 This action is responsible for displaying the stocktaking history at the bottom
1046 of the form. It uses the stocktaking journal with fixed filters for cutoff date
1047 and the current employee. The history is displayed via javascript.
1049 =item C<action_stocktaking_part_changed>
1051 This action is called after the user selected or changed the part.
1053 =item C<action_stocktaking_get_warn_qty_threshold>
1055 This action checks if a warning should be shown and returns the warning text via
1056 ajax. The warning will be shown if the given target value is greater than the
1057 threshold given in the client configuration.
1059 =item C<is_stocktaking>
1061 This is a method to check if actions are called from stocktaking form.
1062 This actions should contain "stocktaking" in their name.
1066 =head1 SPECIAL CASES
1068 Because of the PFD-Table Formatter some parameters for PDF must be different to the HTML parameters.
1069 So in german language there are some tries to use a HTML Break in the second heading line
1070 to produce two line heading inside table. The actual version has some abbreviations for the header texts.
1074 The PDF-Table library has some limits (doesn't display all if the line is to large) so
1075 the format is adapted to this
1082 =item only for C<action_stock_usage> and C<action_usage>:
1084 Martin Helmling E<lt>martin.helmling@opendynamic.deE<gt>
1086 =item for stocktaking:
1088 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>