|
package SL::Controller::Inventory;
|
|
|
|
use strict;
|
|
use warnings;
|
|
use POSIX qw(strftime);
|
|
|
|
use parent qw(SL::Controller::Base);
|
|
|
|
use SL::DB::Inventory;
|
|
use SL::DB::Stocktaking;
|
|
use SL::DB::Part;
|
|
use SL::DB::Warehouse;
|
|
use SL::DB::Unit;
|
|
use SL::DB::Default;
|
|
use SL::WH;
|
|
use SL::ReportGenerator;
|
|
use SL::Locale::String qw(t8);
|
|
use SL::Presenter::Tag qw(select_tag);
|
|
use SL::DBUtils;
|
|
use SL::Helper::Flash;
|
|
use SL::Controller::Helper::ReportGenerator;
|
|
use SL::Controller::Helper::GetModels;
|
|
use List::MoreUtils qw(uniq);
|
|
|
|
use English qw(-no_match_vars);
|
|
|
|
use Rose::Object::MakeMethods::Generic (
|
|
'scalar --get_set_init' => [ qw(warehouses units is_stocktaking stocktaking_models stocktaking_cutoff_date) ],
|
|
'scalar' => [ qw(warehouse bin unit part) ],
|
|
);
|
|
|
|
__PACKAGE__->run_before('_check_auth');
|
|
__PACKAGE__->run_before('_check_warehouses');
|
|
__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) ]);
|
|
__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) ]);
|
|
__PACKAGE__->run_before('load_wh_from_form', only => [ qw(stock_in warehouse_changed stock stocktaking stocktaking_get_warn_qty_threshold save_stocktaking) ]);
|
|
__PACKAGE__->run_before('load_bin_from_form', only => [ qw(stock_in stock stocktaking stocktaking_get_warn_qty_threshold save_stocktaking) ]);
|
|
__PACKAGE__->run_before('set_target_from_part', only => [ qw(part_changed) ]);
|
|
__PACKAGE__->run_before('mini_stock', only => [ qw(stock_in mini_stock) ]);
|
|
__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) ]);
|
|
__PACKAGE__->run_before('set_layout');
|
|
|
|
sub action_stock_in {
|
|
my ($self) = @_;
|
|
|
|
$::form->{title} = t8('Stock');
|
|
|
|
# Sometimes we want to open stock_in with a part already selected, but only
|
|
# the parts_id is passed in the url (and not also warehouse, bin and unit).
|
|
# Setting select_default_bin in the form will make sure the default warehouse
|
|
# and bin of that part will already be preselected, as normally
|
|
# set_target_from_part is only called when a part is changed.
|
|
$self->set_target_from_part if $::form->{select_default_bin};
|
|
$::request->layout->focus('#part_id_name');
|
|
my $transfer_types = WH->retrieve_transfer_types('in');
|
|
map { $_->{description} = $main::locale->text($_->{description}) } @{ $transfer_types };
|
|
$self->setup_stock_in_action_bar;
|
|
$self->render('inventory/warehouse_selection_stock', title => $::form->{title}, TRANSFER_TYPES => $transfer_types );
|
|
}
|
|
|
|
sub action_stock_usage {
|
|
my ($self) = @_;
|
|
|
|
$::form->{title} = t8('UsageE');
|
|
|
|
$::form->get_lists('warehouses' => { 'key' => 'WAREHOUSES',
|
|
'bins' => 'BINS', });
|
|
|
|
$self->setup_stock_usage_action_bar;
|
|
$self->render('inventory/warehouse_usage',
|
|
title => $::form->{title},
|
|
year => DateTime->today->year,
|
|
WAREHOUSES => $::form->{WAREHOUSES},
|
|
WAREHOUSE_FILTER => 1,
|
|
warehouse_id => 0,
|
|
bin_id => 0
|
|
);
|
|
|
|
}
|
|
|
|
sub getnumcolumns {
|
|
my ($self) = @_;
|
|
return qw(stock incorrection found insum back outcorrection disposed
|
|
missing shipped used outsum consumed averconsumed);
|
|
}
|
|
|
|
sub action_usage {
|
|
my ($self) = @_;
|
|
|
|
$main::lxdebug->enter_sub();
|
|
|
|
my $form = $main::form;
|
|
my %myconfig = %main::myconfig;
|
|
my $locale = $main::locale;
|
|
|
|
$form->{title} = t8('UsageE');
|
|
$form->{report_generator_output_format} = 'HTML' if !$form->{report_generator_output_format};
|
|
|
|
my $report = SL::ReportGenerator->new(\%myconfig, $form);
|
|
|
|
my @columns = qw(partnumber partdescription);
|
|
|
|
push @columns , qw(ptype unit) if $form->{report_generator_output_format} eq 'HTML';
|
|
|
|
my @numcolumns = qw(stock incorrection found insum back outcorrection disposed
|
|
missing shipped used outsum consumed averconsumed);
|
|
|
|
push @columns , $self->getnumcolumns();
|
|
|
|
my @hidden_variables = qw(reporttype year duetyp fromdate todate
|
|
warehouse_id bin_id partnumber description bestbefore chargenumber partstypes_id);
|
|
my %column_defs = (
|
|
'partnumber' => { 'text' => $locale->text('Part Number'), },
|
|
'partdescription' => { 'text' => $locale->text('Part_br_Description'), },
|
|
'unit' => { 'text' => $locale->text('Unit'), },
|
|
'stock' => { 'text' => $locale->text('stock_br'), },
|
|
'incorrection' => { 'text' => $locale->text('correction_br'), },
|
|
'found' => { 'text' => $locale->text('found_br'), },
|
|
'insum' => { 'text' => $locale->text('sum'), },
|
|
'back' => { 'text' => $locale->text('back_br'), },
|
|
'outcorrection' => { 'text' => $locale->text('correction_br'), },
|
|
'disposed' => { 'text' => $locale->text('disposed_br'), },
|
|
'missing' => { 'text' => $locale->text('missing_br'), },
|
|
'shipped' => { 'text' => $locale->text('shipped_br'), },
|
|
'used' => { 'text' => $locale->text('used_br'), },
|
|
'outsum' => { 'text' => $locale->text('sum'), },
|
|
'consumed' => { 'text' => $locale->text('consumed'), },
|
|
'averconsumed' => { 'text' => $locale->text('averconsumed_br'), },
|
|
);
|
|
|
|
|
|
map { $column_defs{$_}->{visible} = 1 } @columns;
|
|
#map { $column_defs{$_}->{visible} = $form->{"l_${_}"} ? 1 : 0 } @columns;
|
|
map { $column_defs{$_}->{align} = 'right' } @numcolumns;
|
|
|
|
my @custom_headers = ();
|
|
# Zeile 1:
|
|
push @custom_headers, [
|
|
{ 'text' => $locale->text('Part'),
|
|
'colspan' => ($form->{report_generator_output_format} eq 'HTML'?4:2), 'align' => 'center'},
|
|
{ 'text' => $locale->text('Into bin'), 'colspan' => 4, 'align' => 'center'},
|
|
{ 'text' => $locale->text('From bin'), 'colspan' => 7, 'align' => 'center'},
|
|
{ 'text' => $locale->text('UsageWithout'), 'colspan' => 2, 'align' => 'center'},
|
|
];
|
|
|
|
# Zeile 2:
|
|
my @line_2 = ();
|
|
map { push @line_2 , $column_defs{$_} } @columns;
|
|
push @custom_headers, [ @line_2 ];
|
|
|
|
$report->set_custom_headers(@custom_headers);
|
|
$report->set_columns( %column_defs );
|
|
$report->set_column_order(@columns);
|
|
|
|
$report->set_export_options('usage', @hidden_variables );
|
|
|
|
$report->set_sort_indicator($form->{sort}, $form->{order});
|
|
$report->set_options('output_format' => 'HTML',
|
|
'controller_class' => 'Inventory',
|
|
'title' => $form->{title},
|
|
# 'html_template' => 'inventory/usage_report',
|
|
'attachment_basename' => strftime($locale->text('warehouse_usage_list') . '_%Y%m%d', localtime time));
|
|
$report->set_options_from_form;
|
|
|
|
my %searchparams ;
|
|
# form vars
|
|
# reporttype = custom
|
|
# year = 2014
|
|
# duetyp = 7
|
|
|
|
my $start = DateTime->now_local;
|
|
my $end = DateTime->now_local;
|
|
my $actualepoch = $end->epoch();
|
|
my $days = 365;
|
|
my $mdays=30;
|
|
$searchparams{reporttype} = $form->{reporttype};
|
|
if ($form->{reporttype} eq "custom") {
|
|
my $smon = 1;
|
|
my $emon = 12;
|
|
my $sday = 1;
|
|
my $eday = 31;
|
|
#forgotten the year --> thisyear
|
|
if ($form->{year} !~ m/^\d\d\d\d$/) {
|
|
$locale->date(\%myconfig, $form->current_date(\%myconfig), 0) =~
|
|
/(\d\d\d\d)/;
|
|
$form->{year} = $1;
|
|
}
|
|
my $leapday = ($form->{year} % 4 == 0) ? 1:0;
|
|
#yearly report
|
|
if ($form->{duetyp} eq "13") {
|
|
$days += $leapday;
|
|
}
|
|
|
|
#Quater reports
|
|
if ($form->{duetyp} eq "A") {
|
|
$emon = 3;
|
|
$days = 90 + $leapday;
|
|
}
|
|
if ($form->{duetyp} eq "B") {
|
|
$smon = 4;
|
|
$emon = 6;
|
|
$eday = 30;
|
|
$days = 91;
|
|
}
|
|
if ($form->{duetyp} eq "C") {
|
|
$smon = 7;
|
|
$emon = 9;
|
|
$eday = 30;
|
|
$days = 92;
|
|
}
|
|
if ($form->{duetyp} eq "D") {
|
|
$smon = 10;
|
|
$days = 92;
|
|
}
|
|
#Monthly reports
|
|
if ($form->{duetyp} eq "1" || $form->{duetyp} eq "3" || $form->{duetyp} eq "5" ||
|
|
$form->{duetyp} eq "7" || $form->{duetyp} eq "8" || $form->{duetyp} eq "10" ||
|
|
$form->{duetyp} eq "12") {
|
|
$smon = $emon = $form->{duetyp}*1;
|
|
$mdays=$days = 31;
|
|
}
|
|
if ($form->{duetyp} eq "2" || $form->{duetyp} eq "4" || $form->{duetyp} eq "6" ||
|
|
$form->{duetyp} eq "9" || $form->{duetyp} eq "11" ) {
|
|
$smon = $emon = $form->{duetyp}*1;
|
|
$eday = 30;
|
|
if ($form->{duetyp} eq "2" ) {
|
|
#this works from 1901 to 2099, 1900 and 2100 fail.
|
|
$eday = ($form->{year} % 4 == 0) ? 29 : 28;
|
|
}
|
|
$mdays=$days = $eday;
|
|
}
|
|
$searchparams{year} = $form->{year};
|
|
$searchparams{duetyp} = $form->{duetyp};
|
|
$start->set_month($smon);
|
|
$start->set_day($sday);
|
|
$start->set_year($form->{year}*1);
|
|
$end->set_month($emon);
|
|
$end->set_day($eday);
|
|
$end->set_year($form->{year}*1);
|
|
} else {
|
|
$searchparams{fromdate} = $form->{fromdate};
|
|
$searchparams{todate} = $form->{todate};
|
|
# reporttype = free
|
|
# fromdate = 01.01.2014
|
|
# todate = 31.05.2014
|
|
my ($yy, $mm, $dd) = $locale->parse_date(\%myconfig,$form->{fromdate});
|
|
$start->set_year($yy);
|
|
$start->set_month($mm);
|
|
$start->set_day($dd);
|
|
($yy, $mm, $dd) = $locale->parse_date(\%myconfig,$form->{todate});
|
|
$end->set_year($yy);
|
|
$end->set_month($mm);
|
|
$end->set_day($dd);
|
|
my $dur = $start->delta_md($end);
|
|
$days = $dur->delta_months()*30 + $dur->delta_days() ;
|
|
}
|
|
$start->set_second(0);
|
|
$start->set_minute(0);
|
|
$start->set_hour(0);
|
|
$end->set_second(59);
|
|
$end->set_minute(59);
|
|
$end->set_hour(23);
|
|
if ( $end->epoch() > $actualepoch ) {
|
|
$end = DateTime->now_local;
|
|
my $dur = $start->delta_md($end);
|
|
$days = $dur->delta_months()*30 + $dur->delta_days() ;
|
|
}
|
|
if ( $start->epoch() > $end->epoch() ) { $start = $end;$days = 1;}
|
|
$days = $mdays if $days < $mdays;
|
|
#$main::lxdebug->message(LXDebug->DEBUG2(), "start=".$start->epoch());
|
|
#$main::lxdebug->message(LXDebug->DEBUG2(), " end=".$end->epoch());
|
|
#$main::lxdebug->message(LXDebug->DEBUG2(), " days=".$days);
|
|
my @andfilter = (shippingdate => { ge => $start }, shippingdate => { le => $end } );
|
|
if ( $form->{warehouse_id} ) {
|
|
push @andfilter , ( warehouse_id => $form->{warehouse_id});
|
|
$searchparams{warehouse_id} = $form->{warehouse_id};
|
|
if ( $form->{bin_id} ) {
|
|
push @andfilter , ( bin_id => $form->{bin_id});
|
|
$searchparams{bin_id} = $form->{bin_id};
|
|
}
|
|
}
|
|
# alias class t2 entspricht parts
|
|
if ( $form->{partnumber} ) {
|
|
push @andfilter , ( 't2.partnumber' => { ilike => '%'. $form->{partnumber} .'%' });
|
|
$searchparams{partnumber} = $form->{partnumber};
|
|
}
|
|
if ( $form->{description} ) {
|
|
push @andfilter , ( 't2.description' => { ilike => '%'. $form->{description} .'%' });
|
|
$searchparams{description} = $form->{description};
|
|
}
|
|
if ( $form->{bestbefore} ) {
|
|
push @andfilter , ( bestbefore => { eq => $form->{bestbefore} });
|
|
$searchparams{bestbefore} = $form->{bestbefore};
|
|
}
|
|
if ( $form->{chargenumber} ) {
|
|
push @andfilter , ( chargenumber => { ilike => '%'.$form->{chargenumber}.'%' });
|
|
$searchparams{chargenumber} = $form->{chargenumber};
|
|
}
|
|
if ( $form->{partstypes_id} ) {
|
|
push @andfilter , ( 't2.partstypes_id' => $form->{partstypes_id} );
|
|
$searchparams{partstypes_id} = $form->{partstypes_id};
|
|
}
|
|
|
|
my @filter = (and => [ @andfilter ] );
|
|
|
|
my $objs = SL::DB::Manager::Inventory->get_all(with_objects => ['parts'], where => [ @filter ] , sort_by => 'parts.partnumber ASC');
|
|
#my $objs = SL::DB::Inventory->_get_manager_class->get_all(...);
|
|
|
|
# manual paginating, yuck
|
|
my $page = $::form->{page} || 1;
|
|
my $pages = {};
|
|
$pages->{per_page} = $::form->{per_page} || 20;
|
|
my $first_nr = ($page - 1) * $pages->{per_page};
|
|
my $last_nr = $first_nr + $pages->{per_page};
|
|
|
|
my $last_partid = 0;
|
|
my $last_row = { };
|
|
my $row_ind = 0;
|
|
my $allrows = 0;
|
|
$allrows = 1 if $form->{report_generator_output_format} ne 'HTML' ;
|
|
#$main::lxdebug->message(LXDebug->DEBUG2(), "first_nr=".$first_nr." last_nr=".$last_nr);
|
|
foreach my $entry (@{ $objs } ) {
|
|
if ( $entry->parts_id != $last_partid ) {
|
|
if ( $last_partid > 0 ) {
|
|
if ( $allrows || ($row_ind >= $first_nr && $row_ind < $last_nr )) {
|
|
$self->make_row_result($last_row,$days,$last_partid);
|
|
$report->add_data($last_row);
|
|
}
|
|
$row_ind++ ;
|
|
}
|
|
$last_partid = $entry->parts_id;
|
|
$last_row = { };
|
|
$last_row->{partnumber}->{data} = $entry->part->partnumber;
|
|
$last_row->{partdescription}->{data} = $entry->part->description;
|
|
$last_row->{unit}->{data} = $entry->part->unit;
|
|
$last_row->{stock}->{data} = 0;
|
|
$last_row->{incorrection}->{data} = 0;
|
|
$last_row->{found}->{data} = 0;
|
|
$last_row->{back}->{data} = 0;
|
|
$last_row->{outcorrection}->{data} = 0;
|
|
$last_row->{disposed}->{data} = 0;
|
|
$last_row->{missing}->{data} = 0;
|
|
$last_row->{shipped}->{data} = 0;
|
|
$last_row->{used}->{data} = 0;
|
|
$last_row->{insum}->{data} = 0;
|
|
$last_row->{outsum}->{data} = 0;
|
|
$last_row->{consumed}->{data} = 0;
|
|
$last_row->{averconsumed}->{data} = 0;
|
|
}
|
|
if ( !$allrows && $row_ind >= $last_nr ) {
|
|
next;
|
|
}
|
|
my $prefix='';
|
|
if ( $entry->trans_type->description eq 'correction' ) {
|
|
$prefix = $entry->trans_type->direction;
|
|
}
|
|
$last_row->{$prefix.$entry->trans_type->description}->{data} +=
|
|
( $entry->trans_type->direction eq 'out' ? -$entry->qty : $entry->qty );
|
|
}
|
|
if ( $last_partid > 0 && ( $allrows || ($row_ind >= $first_nr && $row_ind < $last_nr ))) {
|
|
$self->make_row_result($last_row,$days,$last_partid);
|
|
$report->add_data($last_row);
|
|
$row_ind++ ;
|
|
}
|
|
my $num_rows = @{ $report->{data} } ;
|
|
#$main::lxdebug->message(LXDebug->DEBUG2(), "count=".$row_ind." rows=".$num_rows);
|
|
|
|
if ( ! $allrows ) {
|
|
$pages->{max} = SL::DB::Helper::Paginated::ceil($row_ind, $pages->{per_page}) || 1;
|
|
$pages->{page} = $page < 1 ? 1: $page > $pages->{max} ? $pages->{max}: $page;
|
|
$pages->{common} = [ grep { $_->{visible} } @{ SL::DB::Helper::Paginated::make_common_pages($pages->{page}, $pages->{max}) } ];
|
|
$self->{pages} = $pages;
|
|
$searchparams{action} = "usage";
|
|
$self->{base_url} = $self->url_for(\%searchparams );
|
|
#$main::lxdebug->message(LXDebug->DEBUG2(), "page=".$pages->{page}." url=".$self->{base_url});
|
|
|
|
$report->set_options('raw_bottom_info_text' => $self->render('inventory/report_bottom', { output => 0 }) );
|
|
}
|
|
$report->generate_with_headers();
|
|
|
|
$main::lxdebug->leave_sub();
|
|
|
|
}
|
|
|
|
sub make_row_result {
|
|
my ($self,$row,$days,$partid) = @_;
|
|
my $form = $main::form;
|
|
my $myconfig = \%main::myconfig;
|
|
|
|
$row->{insum}->{data} = $row->{stock}->{data} + $row->{incorrection}->{data} + $row->{found}->{data};
|
|
$row->{outsum}->{data} = $row->{back}->{data} + $row->{outcorrection}->{data} + $row->{disposed}->{data} +
|
|
$row->{missing}->{data} + $row->{shipped}->{data} + $row->{used}->{data};
|
|
$row->{consumed}->{data} = $row->{outsum}->{data} -
|
|
$row->{outcorrection}->{data} - $row->{incorrection}->{data};
|
|
$row->{averconsumed}->{data} = $row->{consumed}->{data}*30/$days ;
|
|
map { $row->{$_}->{data} = $form->format_amount($myconfig,$row->{$_}->{data},2); } $self->getnumcolumns();
|
|
$row->{partnumber}->{link} = 'controller.pl?action=Part/edit&part.id=' . $partid;
|
|
}
|
|
|
|
sub action_stock {
|
|
my ($self) = @_;
|
|
|
|
my $transfer_error;
|
|
my $qty = $::form->parse_amount(\%::myconfig, $::form->{qty});
|
|
if (!$qty) {
|
|
$transfer_error = t8('Cannot stock without amount');
|
|
} elsif ($qty < 0) {
|
|
$transfer_error = t8('Cannot stock negative amounts');
|
|
} else {
|
|
# do stock
|
|
$::form->throw_on_error(sub {
|
|
eval {
|
|
WH->transfer({
|
|
parts => $self->part,
|
|
dst_bin => $self->bin,
|
|
dst_wh => $self->warehouse,
|
|
qty => $qty,
|
|
unit => $self->unit,
|
|
transfer_type => 'stock',
|
|
transfer_type_id => $::form->{transfer_type_id},
|
|
chargenumber => $::form->{chargenumber},
|
|
bestbefore => $::form->{bestbefore},
|
|
comment => $::form->{comment},
|
|
});
|
|
1;
|
|
} or do { $transfer_error = $EVAL_ERROR->error; }
|
|
});
|
|
|
|
if (!$transfer_error) {
|
|
if ($::form->{write_default_bin}) {
|
|
$self->part->load; # onhand is calculated in between. don't mess that up
|
|
$self->part->bin($self->bin);
|
|
$self->part->warehouse($self->warehouse);
|
|
$self->part->save;
|
|
}
|
|
|
|
flash_later('info', t8('Transfer successful'));
|
|
}
|
|
}
|
|
|
|
my %additional_redirect_params = ();
|
|
if ($transfer_error) {
|
|
flash_later('error', $transfer_error);
|
|
$additional_redirect_params{$_} = $::form->{$_} for qw(qty chargenumber bestbefore ean comment);
|
|
$additional_redirect_params{qty} = $qty;
|
|
}
|
|
|
|
# redirect
|
|
$self->redirect_to(
|
|
action => 'stock_in',
|
|
part_id => $self->part->id,
|
|
bin_id => $self->bin->id,
|
|
warehouse_id => $self->warehouse->id,
|
|
unit_id => $self->unit->id,
|
|
%additional_redirect_params,
|
|
);
|
|
}
|
|
|
|
sub action_part_changed {
|
|
my ($self) = @_;
|
|
|
|
# no standard? ask user if he wants to write it
|
|
if ($self->part->id && !$self->part->bin_id && !$self->part->warehouse_id) {
|
|
$self->js->show('#write_default_bin_span');
|
|
} else {
|
|
$self->js->hide('#write_default_bin_span')
|
|
->removeAttr('#write_default_bin', 'checked');
|
|
}
|
|
|
|
$self->js
|
|
->replaceWith('#warehouse_id', $self->build_warehouse_select)
|
|
->replaceWith('#bin_id', $self->build_bin_select)
|
|
->replaceWith('#unit_id', $self->build_unit_select)
|
|
->focus('#warehouse_id')
|
|
->render;
|
|
}
|
|
|
|
sub action_warehouse_changed {
|
|
my ($self) = @_;
|
|
|
|
$self->js
|
|
->replaceWith('#bin_id', $self->build_bin_select)
|
|
->focus('#bin_id')
|
|
->render;
|
|
}
|
|
|
|
sub action_mini_stock {
|
|
my ($self) = @_;
|
|
|
|
$self->js
|
|
->html('#stock', $self->render('inventory/_stock', { output => 0 }))
|
|
->render;
|
|
}
|
|
|
|
sub action_stocktaking {
|
|
my ($self) = @_;
|
|
|
|
$::request->{layout}->use_javascript("${_}.js") for qw(kivi.Inventory);
|
|
$::request->layout->focus('#part_id_name');
|
|
$self->setup_stock_stocktaking_action_bar;
|
|
$self->render('inventory/stocktaking/form', title => t8('Stocktaking'));
|
|
}
|
|
|
|
sub action_save_stocktaking {
|
|
my ($self) = @_;
|
|
|
|
return $self->js->flash('error', t8('Please choose a part.'))->render()
|
|
if !$::form->{part_id};
|
|
|
|
return $self->js->flash('error', t8('A target quantitiy has to be given'))->render()
|
|
if $::form->{target_qty} eq '';
|
|
|
|
my $target_qty = $::form->parse_amount(\%::myconfig, $::form->{target_qty});
|
|
|
|
return $self->js->flash('error', t8('Error: A negative target quantity is not allowed.'))->render()
|
|
if $target_qty < 0;
|
|
|
|
my $stocked_qty = _get_stocked_qty($self->part,
|
|
warehouse_id => $self->warehouse->id,
|
|
bin_id => $self->bin->id,
|
|
chargenumber => $::form->{chargenumber},
|
|
bestbefore => $::form->{bestbefore},);
|
|
|
|
my $stocked_qty_in_form_units = $self->part->unit_obj->convert_to($stocked_qty, $self->unit);
|
|
|
|
if (!$::form->{dont_check_already_counted}) {
|
|
my $already_counted = _already_counted($self->part,
|
|
warehouse_id => $self->warehouse->id,
|
|
bin_id => $self->bin->id,
|
|
cutoff_date => $::form->{cutoff_date_as_date},
|
|
chargenumber => $::form->{chargenumber},
|
|
bestbefore => $::form->{bestbefore});
|
|
if (scalar @$already_counted) {
|
|
my $reply = $self->js->dialog->open({
|
|
html => $self->render('inventory/stocktaking/_already_counted_dialog',
|
|
{ output => 0 },
|
|
already_counted => $already_counted,
|
|
stocked_qty => $stocked_qty,
|
|
stocked_qty_in_form_units => $stocked_qty_in_form_units),
|
|
id => 'already_counted_dialog',
|
|
dialog => {
|
|
title => t8('Already counted'),
|
|
},
|
|
})->render;
|
|
|
|
return $reply;
|
|
}
|
|
}
|
|
|
|
# - target_qty is in units given in form ($self->unit)
|
|
# - WH->transfer expects qtys in given unit (here: unit from form (unit -> $self->unit))
|
|
# Therefore use stocked_qty in form units for calculation.
|
|
my $qty = $target_qty - $stocked_qty_in_form_units;
|
|
my $src_or_dst = $qty < 0? 'src' : 'dst';
|
|
$qty = abs($qty);
|
|
|
|
my $transfer_error;
|
|
# do stock
|
|
$::form->throw_on_error(sub {
|
|
eval {
|
|
WH->transfer({
|
|
parts => $self->part,
|
|
$src_or_dst.'_bin' => $self->bin,
|
|
$src_or_dst.'_wh' => $self->warehouse,
|
|
qty => $qty,
|
|
unit => $self->unit,
|
|
transfer_type => 'stocktaking',
|
|
chargenumber => $::form->{chargenumber},
|
|
bestbefore => $::form->{bestbefore},
|
|
ean => $::form->{ean},
|
|
comment => $::form->{comment},
|
|
record_stocktaking => 1,
|
|
stocktaking_qty => $target_qty,
|
|
stocktaking_cutoff_date => $::form->{cutoff_date_as_date},
|
|
});
|
|
1;
|
|
} or do { $transfer_error = $EVAL_ERROR->error; }
|
|
});
|
|
|
|
return $self->js->flash('error', $transfer_error)->render()
|
|
if $transfer_error;
|
|
|
|
flash_later('info', $::locale->text('Part successful counted'));
|
|
$self->redirect_to(action => 'stocktaking',
|
|
warehouse_id => $self->warehouse->id,
|
|
bin_id => $self->bin->id,
|
|
cutoff_date_as_date => $self->stocktaking_cutoff_date->to_kivitendo);
|
|
}
|
|
|
|
sub action_reload_stocktaking_history {
|
|
my ($self) = @_;
|
|
|
|
$::form->{filter}{'cutoff_date:date'} = $self->stocktaking_cutoff_date->to_kivitendo;
|
|
$::form->{filter}{'employee_id'} = SL::DB::Manager::Employee->current->id;
|
|
|
|
$self->prepare_stocktaking_report;
|
|
$self->report_generator_list_objects(report => $self->{report}, objects => $self->stocktaking_models->get, layout => 0, header => 0);
|
|
}
|
|
|
|
sub action_stocktaking_part_changed {
|
|
my ($self) = @_;
|
|
|
|
$self->js
|
|
->replaceWith('#unit_id', $self->build_unit_select)
|
|
->focus('#target_qty')
|
|
->render;
|
|
}
|
|
|
|
sub action_stocktaking_journal {
|
|
my ($self) = @_;
|
|
|
|
$self->prepare_stocktaking_report(full => 1);
|
|
$self->report_generator_list_objects(report => $self->{report}, objects => $self->stocktaking_models->get);
|
|
}
|
|
|
|
sub action_stocktaking_get_warn_qty_threshold {
|
|
my ($self) = @_;
|
|
|
|
return $_[0]->render(\ !!0, { type => 'text' }) if !$::form->{part_id};
|
|
return $_[0]->render(\ !!0, { type => 'text' }) if $::form->{target_qty} eq '';
|
|
return $_[0]->render(\ !!0, { type => 'text' }) if 0 == $::instance_conf->get_stocktaking_qty_threshold;
|
|
|
|
my $target_qty = $::form->parse_amount(\%::myconfig, $::form->{target_qty});
|
|
my $stocked_qty = _get_stocked_qty($self->part,
|
|
warehouse_id => $self->warehouse->id,
|
|
bin_id => $self->bin->id,
|
|
chargenumber => $::form->{chargenumber},
|
|
bestbefore => $::form->{bestbefore},);
|
|
my $stocked_qty_in_form_units = $self->part->unit_obj->convert_to($stocked_qty, $self->unit);
|
|
my $qty = $target_qty - $stocked_qty_in_form_units;
|
|
$qty = abs($qty);
|
|
|
|
my $warn;
|
|
if ($qty > $::instance_conf->get_stocktaking_qty_threshold) {
|
|
$warn = t8('The target quantity of #1 differs more than the threshold quantity of #2.',
|
|
$::form->{target_qty} . " " . $self->unit->name,
|
|
$::form->format_amount(\%::myconfig, $::instance_conf->get_stocktaking_qty_threshold, 2));
|
|
$warn .= "\n";
|
|
$warn .= t8('Choose "continue" if you want to use this value. Choose "cancel" otherwise.');
|
|
}
|
|
return $_[0]->render(\ $warn, { type => 'text' });
|
|
}
|
|
|
|
#================================================================
|
|
|
|
sub _check_auth {
|
|
$main::auth->assert('warehouse_management');
|
|
}
|
|
|
|
sub _check_warehouses {
|
|
$_[0]->show_no_warehouses_error if !@{ $_[0]->warehouses };
|
|
}
|
|
|
|
sub init_warehouses {
|
|
SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef ]]);
|
|
}
|
|
|
|
#sub init_bins {
|
|
# SL::DB::Manager::Bin->get_all();
|
|
#}
|
|
|
|
sub init_units {
|
|
SL::DB::Manager::Unit->get_all;
|
|
}
|
|
|
|
sub init_is_stocktaking {
|
|
return $_[0]->action_name =~ m{stocktaking};
|
|
}
|
|
|
|
sub init_stocktaking_models {
|
|
my ($self) = @_;
|
|
|
|
SL::Controller::Helper::GetModels->new(
|
|
controller => $self,
|
|
model => 'Stocktaking',
|
|
sorted => {
|
|
_default => {
|
|
by => 'itime',
|
|
dir => 0,
|
|
},
|
|
itime => t8('Insert Date'),
|
|
qty => t8('Target Qty'),
|
|
chargenumber => t8('Charge Number'),
|
|
comment => t8('Comment'),
|
|
employee => t8('Employee'),
|
|
ean => t8('EAN'),
|
|
partnumber => t8('Part Number'),
|
|
part => t8('Part Description'),
|
|
bin => t8('Bin'),
|
|
cutoff_date => t8('Cutoff Date'),
|
|
},
|
|
with_objects => ['employee', 'parts', 'warehouse', 'bin'],
|
|
);
|
|
}
|
|
|
|
sub init_stocktaking_cutoff_date {
|
|
my ($self) = @_;
|
|
|
|
return DateTime->from_kivitendo($::form->{cutoff_date_as_date}) if $::form->{cutoff_date_as_date};
|
|
return SL::DB::Default->get->stocktaking_cutoff_date if SL::DB::Default->get->stocktaking_cutoff_date;
|
|
|
|
# Default cutoff date is last day of current year, but if current month
|
|
# is janurary, it is the last day of the last year.
|
|
my $now = DateTime->now_local;
|
|
my $cutoff = DateTime->new(year => $now->year, month => 12, day => 31);
|
|
if ($now->month < 1) {
|
|
$cutoff->subtract(years => 1);
|
|
}
|
|
return $cutoff;
|
|
}
|
|
|
|
sub set_target_from_part {
|
|
my ($self) = @_;
|
|
|
|
return if !$self->part;
|
|
|
|
$self->warehouse($self->part->warehouse) if $self->part->warehouse;
|
|
$self->bin( $self->part->bin) if $self->part->bin;
|
|
}
|
|
|
|
sub sanitize_target {
|
|
my ($self) = @_;
|
|
|
|
$self->warehouse($self->warehouses->[0]) if !$self->warehouse || !$self->warehouse->id;
|
|
$self->bin ($self->warehouse->bins->[0]) if !$self->bin || !$self->bin->id;
|
|
# foreach my $warehouse ( $self->warehouses ) {
|
|
# $warehouse->{BINS} = [];
|
|
# foreach my $bin ( $self->bins ) {
|
|
# if ( $bin->warehouse_id == $warehouse->id ) {
|
|
# push @{ $warehouse->{BINS} }, $bin;
|
|
# }
|
|
# }
|
|
# }
|
|
}
|
|
|
|
sub load_part_from_form {
|
|
$_[0]->part(SL::DB::Manager::Part->find_by_or_create(id => $::form->{part_id}||undef));
|
|
}
|
|
|
|
sub load_unit_from_form {
|
|
$_[0]->unit(SL::DB::Manager::Unit->find_by_or_create(id => $::form->{unit_id}));
|
|
}
|
|
|
|
sub load_wh_from_form {
|
|
my $preselected;
|
|
$preselected = SL::DB::Default->get->stocktaking_warehouse_id if $_[0]->is_stocktaking;
|
|
|
|
$_[0]->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => ($::form->{warehouse_id} || $preselected)));
|
|
}
|
|
|
|
sub load_bin_from_form {
|
|
my $preselected;
|
|
$preselected = SL::DB::Default->get->stocktaking_bin_id if $_[0]->is_stocktaking;
|
|
|
|
$_[0]->bin(SL::DB::Manager::Bin->find_by_or_create(id => ($::form->{bin_id} || $preselected)));
|
|
}
|
|
|
|
sub set_layout {
|
|
$::request->layout->add_javascripts('client_js.js');
|
|
}
|
|
|
|
sub build_warehouse_select {
|
|
select_tag('warehouse_id', $_[0]->warehouses,
|
|
title_key => 'description',
|
|
default => $_[0]->warehouse->id,
|
|
onchange => 'reload_bin_selection()',
|
|
)
|
|
}
|
|
|
|
sub build_bin_select {
|
|
select_tag('bin_id', [ $_[0]->warehouse->bins ],
|
|
title_key => 'description',
|
|
default => $_[0]->bin->id,
|
|
);
|
|
}
|
|
|
|
sub build_unit_select {
|
|
$_[0]->part->id
|
|
? select_tag('unit_id', $_[0]->part->available_units,
|
|
title_key => 'name',
|
|
default => $_[0]->part->unit_obj->id,
|
|
)
|
|
: select_tag('unit_id', $_[0]->units,
|
|
title_key => 'name',
|
|
)
|
|
}
|
|
|
|
sub mini_journal {
|
|
my ($self) = @_;
|
|
|
|
# We want to fetch the last 10 inventory events (inventory rows with the same trans_id)
|
|
# To prevent a Seq Scan on inventory set an index on inventory.itime
|
|
# Each event may have one (transfer_in/out) or two (transfer) inventory rows
|
|
# So fetch the last 20, group by trans_id, limit to the last 10 trans_ids,
|
|
# and then extract the inventory ids from those 10 trans_ids
|
|
# By querying Inventory->get_all via the id instead of trans_id we can make
|
|
# use of the existing index on id
|
|
|
|
# inventory ids of the most recent 10 inventory trans_ids
|
|
my $query = <<SQL;
|
|
with last_inventories as (
|
|
select id,
|
|
trans_id,
|
|
itime
|
|
from inventory
|
|
order by itime desc
|
|
limit 20
|
|
),
|
|
grouped_ids as (
|
|
select trans_id,
|
|
array_agg(id) as ids
|
|
from last_inventories
|
|
group by trans_id
|
|
order by max(itime)
|
|
desc limit 10
|
|
)
|
|
select unnest(ids)
|
|
from grouped_ids
|
|
limit 20 -- so the planner knows how many ids to expect, the cte is an optimisation fence
|
|
SQL
|
|
|
|
my $objs = SL::DB::Manager::Inventory->get_all(
|
|
query => [ id => [ \"$query" ] ], # " make emacs happy
|
|
with_objects => [ 'parts', 'trans_type', 'bin', 'bin.warehouse' ], # prevent lazy loading in template
|
|
sort_by => 'itime DESC',
|
|
);
|
|
# remember order of trans_ids from query, for ordering hash later
|
|
my @sorted_trans_ids = uniq map { $_->trans_id } @$objs;
|
|
|
|
# at most 2 of them belong to a transaction and the qty determines in or out.
|
|
my %transactions;
|
|
for (@$objs) {
|
|
$transactions{ $_->trans_id }{ $_->qty > 0 ? 'in' : 'out' } = $_;
|
|
$transactions{ $_->trans_id }{base} = $_;
|
|
}
|
|
|
|
# because the inventory transactions were built in a hash, we need to sort the
|
|
# hash by using the original sort order of the trans_ids
|
|
my @sorted = map { $transactions{$_} } @sorted_trans_ids;
|
|
|
|
return \@sorted;
|
|
}
|
|
|
|
sub mini_stock {
|
|
my ($self) = @_;
|
|
|
|
my $stock = $self->part->get_simple_stock;
|
|
$self->{stock_by_bin} = { map { $_->{bin_id} => $_ } @$stock };
|
|
$self->{stock_empty} = ! grep { $_->{sum} * 1 } @$stock;
|
|
}
|
|
|
|
sub show_no_warehouses_error {
|
|
my ($self) = @_;
|
|
|
|
my $msg = t8('No warehouse has been created yet or the quantity of the bins is not configured yet.') . ' ';
|
|
|
|
if ($::auth->check_right($::myconfig{login}, 'config')) { # TODO wut?
|
|
$msg .= t8('You can create warehouses and bins via the menu "System -> Warehouses".');
|
|
} else {
|
|
$msg .= t8('Please ask your administrator to create warehouses and bins.');
|
|
}
|
|
$::form->show_generic_error($msg);
|
|
}
|
|
|
|
sub prepare_stocktaking_report {
|
|
my ($self, %params) = @_;
|
|
|
|
my $callback = $self->stocktaking_models->get_callback;
|
|
|
|
my $report = SL::ReportGenerator->new(\%::myconfig, $::form);
|
|
$self->{report} = $report;
|
|
|
|
my @columns = qw(itime employee ean partnumber part qty unit bin chargenumber comment cutoff_date);
|
|
my @sortable = qw(itime employee ean partnumber part qty bin chargenumber comment cutoff_date);
|
|
|
|
my %column_defs = (
|
|
itime => { sub => sub { $_[0]->itime_as_timestamp },
|
|
text => t8('Insert Date'), },
|
|
employee => { sub => sub { $_[0]->employee->safe_name },
|
|
text => t8('Employee'), },
|
|
ean => { sub => sub { $_[0]->part->ean },
|
|
text => t8('EAN'), },
|
|
partnumber => { sub => sub { $_[0]->part->partnumber },
|
|
text => t8('Part Number'), },
|
|
part => { sub => sub { $_[0]->part->description },
|
|
text => t8('Part Description'), },
|
|
qty => { sub => sub { $_[0]->qty_as_number },
|
|
text => t8('Target Qty'),
|
|
align => 'right', },
|
|
unit => { sub => sub { $_[0]->part->unit },
|
|
text => t8('Unit'), },
|
|
bin => { sub => sub { $_[0]->bin->full_description },
|
|
text => t8('Bin'), },
|
|
chargenumber => { text => t8('Charge Number'), },
|
|
comment => { text => t8('Comment'), },
|
|
cutoff_date => { sub => sub { $_[0]->cutoff_date_as_date },
|
|
text => t8('Cutoff Date'), },
|
|
);
|
|
|
|
$report->set_options(
|
|
std_column_visibility => 1,
|
|
controller_class => 'Inventory',
|
|
output_format => 'HTML',
|
|
title => (!!$params{full})? $::locale->text('Stocktaking Journal') : $::locale->text('Stocktaking History'),
|
|
allow_pdf_export => !!$params{full},
|
|
allow_csv_export => !!$params{full},
|
|
);
|
|
$report->set_columns(%column_defs);
|
|
$report->set_column_order(@columns);
|
|
$report->set_export_options(qw(stocktaking_journal filter));
|
|
$report->set_options_from_form;
|
|
$self->stocktaking_models->disable_plugin('paginated') if $report->{options}{output_format} =~ /^(pdf|csv)$/i;
|
|
$self->stocktaking_models->set_report_generator_sort_options(report => $report, sortable_columns => \@sortable) if !!$params{full};
|
|
if (!!$params{full}) {
|
|
$report->set_options(
|
|
raw_top_info_text => $self->render('inventory/stocktaking/full_report_top', { output => 0 }),
|
|
);
|
|
}
|
|
$report->set_options(
|
|
raw_bottom_info_text => $self->render('inventory/stocktaking/report_bottom', { output => 0 }),
|
|
);
|
|
}
|
|
|
|
sub _get_stocked_qty {
|
|
my ($part, %params) = @_;
|
|
|
|
my $bestbefore_filter = '';
|
|
my $bestbefore_val_cnt = 0;
|
|
if ($::instance_conf->get_show_bestbefore) {
|
|
$bestbefore_filter = ($params{bestbefore}) ? 'AND bestbefore = ?' : 'AND bestbefore IS NULL';
|
|
$bestbefore_val_cnt = ($params{bestbefore}) ? 1 : 0;
|
|
}
|
|
|
|
my $query = <<SQL;
|
|
SELECT sum(qty) FROM inventory
|
|
WHERE parts_id = ? AND warehouse_id = ? AND bin_id = ? AND chargenumber = ? $bestbefore_filter
|
|
GROUP BY warehouse_id, bin_id, chargenumber
|
|
SQL
|
|
|
|
my @values = ($part->id,
|
|
$params{warehouse_id},
|
|
$params{bin_id},
|
|
$params{chargenumber});
|
|
push @values, $params{bestbefore} if $bestbefore_val_cnt;
|
|
|
|
my ($stocked_qty) = selectrow_query($::form, $::form->get_standard_dbh, $query, @values);
|
|
|
|
return 1*($stocked_qty || 0);
|
|
}
|
|
|
|
sub _already_counted {
|
|
my ($part, %params) = @_;
|
|
|
|
my %bestbefore_filter;
|
|
if ($::instance_conf->get_show_bestbefore) {
|
|
%bestbefore_filter = (bestbefore => ($params{bestbefore} || undef));
|
|
}
|
|
|
|
SL::DB::Manager::Stocktaking->get_all(query => [and => [parts_id => $part->id,
|
|
warehouse_id => $params{warehouse_id},
|
|
bin_id => $params{bin_id},
|
|
cutoff_date => $params{cutoff_date},
|
|
chargenumber => $params{chargenumber},
|
|
%bestbefore_filter]],
|
|
sort_by => ['itime DESC']);
|
|
}
|
|
|
|
sub setup_stock_in_action_bar {
|
|
my ($self, %params) = @_;
|
|
|
|
for my $bar ($::request->layout->get('actionbar')) {
|
|
$bar->add(
|
|
action => [
|
|
t8('Stock'),
|
|
submit => [ '#form', { action => 'Inventory/stock' } ],
|
|
checks => [ 'check_part_selection_before_stocking' ],
|
|
accesskey => 'enter',
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
sub setup_stock_usage_action_bar {
|
|
my ($self, %params) = @_;
|
|
|
|
for my $bar ($::request->layout->get('actionbar')) {
|
|
$bar->add(
|
|
action => [
|
|
t8('Show'),
|
|
submit => [ '#form', { action => 'Inventory/usage' } ],
|
|
accesskey => 'enter',
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
sub setup_stock_stocktaking_action_bar {
|
|
my ($self, %params) = @_;
|
|
|
|
for my $bar ($::request->layout->get('actionbar')) {
|
|
$bar->add(
|
|
action => [
|
|
t8('Save'),
|
|
checks => [ 'kivi.Inventory.check_stocktaking_qty_threshold' ],
|
|
call => [ 'kivi.Inventory.save_stocktaking' ],
|
|
accesskey => 'enter',
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
1;
|
|
__END__
|
|
|
|
=encoding utf-8
|
|
|
|
=head1 NAME
|
|
|
|
SL::Controller::Inventory - Controller for inventory
|
|
|
|
=head1 DESCRIPTION
|
|
|
|
This controller handles stock in, stocktaking and reports about inventory
|
|
in warehouses/stocks
|
|
|
|
- warehouse content
|
|
|
|
- warehouse journal
|
|
|
|
- warehouse withdrawal
|
|
|
|
- stocktaking
|
|
|
|
=head2 Stocktaking
|
|
|
|
Stocktaking allows to document the counted quantities of parts during
|
|
stocktaking for a certain cutoff date. Differences between counted and stocked
|
|
quantities are corrected in the stock. The transfer type 'stocktacking' is set
|
|
here.
|
|
|
|
After picking a part, the mini stock for this part is displayed. At the bottom
|
|
of the form a history of already counted parts for the current employee and the
|
|
choosen cutoff date is shown.
|
|
|
|
Warehouse, bin and cutoff date canbe preselected in the client configuration.
|
|
|
|
If a part was already counted for this cutoff date, warehouse and bin, a warning
|
|
is displayed, allowing the user to choose to add the counted quantity to the
|
|
stocked one or to take his counted quantity as the new stocked quantity.
|
|
|
|
There is also a journal of stocktakings.
|
|
|
|
Templates are located under C<templates/webpages/inventory/stocktaking>.
|
|
JavaScript functions can be found in C<js/kivi.Inventory.js>.
|
|
|
|
=head1 FUNCTIONS
|
|
|
|
=over 4
|
|
|
|
=item C<action_stock_usage>
|
|
|
|
Create a search form for stock withdrawal.
|
|
The search parameter for report are made like the reports in bin/mozilla/rp.pl
|
|
|
|
=item C<action_usage>
|
|
|
|
Make a report about stock withdrawal.
|
|
|
|
The manual pagination is implemented like the pagination in SL::Controller::CsvImport.
|
|
|
|
=item C<action_stocktaking>
|
|
|
|
This action renders the input form for stocktaking.
|
|
|
|
=item C<action_save_stocktaking>
|
|
|
|
This action saves the stocktaking values and corrects the stock after checking
|
|
if the part is already counted for this warehouse, bin and cutoff date.
|
|
For saving SL::WH->transfer is called.
|
|
|
|
=item C<action_reload_stocktaking_history>
|
|
|
|
This action is responsible for displaying the stocktaking history at the bottom
|
|
of the form. It uses the stocktaking journal with fixed filters for cutoff date
|
|
and the current employee. The history is displayed via javascript.
|
|
|
|
=item C<action_stocktaking_part_changed>
|
|
|
|
This action is called after the user selected or changed the part.
|
|
|
|
=item C<action_stocktaking_get_warn_qty_threshold>
|
|
|
|
This action checks if a warning should be shown and returns the warning text via
|
|
ajax. The warning will be shown if the given target value is greater than the
|
|
threshold given in the client configuration.
|
|
|
|
=item C<is_stocktaking>
|
|
|
|
This is a method to check if actions are called from stocktaking form.
|
|
This actions should contain "stocktaking" in their name.
|
|
|
|
=back
|
|
|
|
=head1 SPECIAL CASES
|
|
|
|
Because of the PFD-Table Formatter some parameters for PDF must be different to the HTML parameters.
|
|
So in german language there are some tries to use a HTML Break in the second heading line
|
|
to produce two line heading inside table. The actual version has some abbreviations for the header texts.
|
|
|
|
=head1 BUGS
|
|
|
|
The PDF-Table library has some limits (doesn't display all if the line is to large) so
|
|
the format is adapted to this
|
|
|
|
|
|
=head1 AUTHOR
|
|
|
|
=over 4
|
|
|
|
=item only for C<action_stock_usage> and C<action_usage>:
|
|
|
|
Martin Helmling E<lt>martin.helmling@opendynamic.deE<gt>
|
|
|
|
=item for stocktaking:
|
|
|
|
Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>
|
|
|
|
=back
|
|
|
|
=cut
|