commit 51072516787d639c5f8df4d487155b4a66b16d6f
Author: Bernd Bleßmann <bernd@kivitendo-premium.de>
Date:   Mon Nov 27 18:13:47 2017 +0100

    Inventur: Eingabemaske und Journal im Inventory-Controller

diff --git a/SL/Controller/Inventory.pm b/SL/Controller/Inventory.pm
index 866c15353..792aa2c9b 100644
--- a/SL/Controller/Inventory.pm
+++ b/SL/Controller/Inventory.pm
@@ -7,9 +7,11 @@ 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);
@@ -17,23 +19,24 @@ 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 English qw(-no_match_vars);
 
 use Rose::Object::MakeMethods::Generic (
-  'scalar --get_set_init' => [ qw(warehouses units) ],
+  '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) ]);
-__PACKAGE__->run_before('load_unit_from_form',   only => [ qw(stock_in part_changed mini_stock stock) ]);
-__PACKAGE__->run_before('load_wh_from_form',     only => [ qw(stock_in warehouse_changed stock) ]);
-__PACKAGE__->run_before('load_bin_from_form',    only => [ qw(stock_in stock) ]);
+__PACKAGE__->run_before('load_part_from_form',   only => [ qw(stock_in part_changed mini_stock stock stocktaking_part_changed save_stocktaking) ]);
+__PACKAGE__->run_before('load_unit_from_form',   only => [ qw(stock_in part_changed mini_stock stock stocktaking_part_changed save_stocktaking) ]);
+__PACKAGE__->run_before('load_wh_from_form',     only => [ qw(stock_in warehouse_changed stock stocktaking save_stocktaking) ]);
+__PACKAGE__->run_before('load_bin_from_form',    only => [ qw(stock_in stock stocktaking 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) ]);
+__PACKAGE__->run_before('sanitize_target',       only => [ qw(stock_usage stock_in warehouse_changed part_changed stocktaking stocktaking_part_changed save_stocktaking) ]);
 __PACKAGE__->run_before('set_layout');
 
 sub action_stock_in {
@@ -483,6 +486,123 @@ sub action_mini_stock {
     ->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('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->getMessage; }
+  });
+
+  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 _check_auth {
@@ -505,6 +625,52 @@ 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->substract(years => 1);
+  }
+  return $cutoff;
+}
+
 sub set_target_from_part {
   my ($self) = @_;
 
@@ -538,11 +704,17 @@ sub load_unit_from_form {
 }
 
 sub load_wh_from_form {
-  $_[0]->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
+  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 {
-  $_[0]->bin(SL::DB::Manager::Bin->find_by_or_create(id => $::form->{bin_id}));
+  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 {
@@ -619,6 +791,109 @@ sub show_no_warehouses_error {
   $::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});
+  }
+
+  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) = @_;
 
@@ -648,6 +923,20 @@ sub setup_stock_usage_action_bar {
   }
 }
 
+sub setup_stock_stocktaking_action_bar {
+  my ($self, %params) = @_;
+
+  for my $bar ($::request->layout->get('actionbar')) {
+    $bar->add(
+      action => [
+        t8('Save'),
+        call      => [ 'kivi.Inventory.save_stocktaking' ],
+        accesskey => 'enter',
+      ],
+    );
+  }
+}
+
 1;
 __END__
 
@@ -655,11 +944,12 @@ __END__
 
 =head1 NAME
 
-SL::Controller::Inventory - Report Controller for inventory
+SL::Controller::Inventory - Controller for inventory
 
 =head1 DESCRIPTION
 
-This controller makes three reports about inventory in warehouses/stocks
+This controller handles stock in, stocktaking and reports about inventory
+in warehouses/stocks
 
 - warehouse content
 
@@ -667,6 +957,30 @@ This controller makes three reports about inventory in warehouses/stocks
 
 - 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
@@ -682,6 +996,30 @@ 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<is_stocktaking>
+
+This is a method to check if actions are called from stocktaking form.
+
 =back
 
 =head1 SPECIAL CASES
@@ -698,9 +1036,16 @@ the format is adapted to this
 
 =head1 AUTHOR
 
-only for C<action_stock_usage> and C<action_usage>:
+=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
diff --git a/SL/DB/Manager/Stocktaking.pm b/SL/DB/Manager/Stocktaking.pm
index 6e6714c87..3481343d9 100644
--- a/SL/DB/Manager/Stocktaking.pm
+++ b/SL/DB/Manager/Stocktaking.pm
@@ -1,14 +1,34 @@
-# This file has been auto-generated only because it didn't exist.
-# Feel free to modify it at will; it will not be overwritten automatically.
-
 package SL::DB::Manager::Stocktaking;
 
 use strict;
 
 use parent qw(SL::DB::Helper::Manager);
 
+use SL::DB::Helper::Filtered;
+use SL::DB::Helper::Paginated;
+use SL::DB::Helper::Sorted;
+
 sub object_class { 'SL::DB::Stocktaking' }
 
 __PACKAGE__->make_manager_methods;
 
+sub _sort_spec {
+  return (
+    default        => [ 'itime', 1 ],
+    columns        => {
+      SIMPLE       => 'ALL',
+      comment      => 'lower(comment)',
+      chargenumber => 'lower(chargenumber)',
+      employee     => 'lower(employee.name)',
+      ean          => 'lower(parts.ean)',
+      partnumber   => 'lower(parts.partnumber)',
+      part         => 'lower(parts.description)',
+      bin          => ['lower(warehouse.description)', 'lower(bin.description)'],
+    });
+}
+
+sub default_objects_per_page {
+  20;
+}
+
 1;
diff --git a/SL/DB/Stocktaking.pm b/SL/DB/Stocktaking.pm
index ee7926b39..4ecd476de 100644
--- a/SL/DB/Stocktaking.pm
+++ b/SL/DB/Stocktaking.pm
@@ -10,4 +10,9 @@ use SL::DB::Manager::Stocktaking;
 
 __PACKAGE__->meta->initialize;
 
+# part accessor is badly named
+sub part {
+  goto &parts;
+}
+
 1;
diff --git a/js/kivi.Inventory.js b/js/kivi.Inventory.js
new file mode 100644
index 000000000..64e877fb0
--- /dev/null
+++ b/js/kivi.Inventory.js
@@ -0,0 +1,63 @@
+namespace('kivi.Inventory', function(ns) {
+  ns.reload_bin_selection = function() {
+    $.post("controller.pl", { action: 'Inventory/warehouse_changed',
+                              warehouse_id: function(){ return $('#warehouse_id').val() } },
+           kivi.eval_json_result);
+  };
+
+  ns.save_stocktaking = function(dont_check_already_counted) {
+    var data = $('#stocktaking_form').serializeArray();
+    data.push({ name: 'action', value: 'Inventory/save_stocktaking' });
+    data.push({ name: 'dont_check_already_counted', value: dont_check_already_counted });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.stocktaking_part_changed = function() {
+    var data = $('#stocktaking_form').serializeArray();
+    data.push({ name: 'action', value: 'Inventory/stocktaking_part_changed' });
+    $.post("controller.pl", data, kivi.eval_json_result);
+    $.post("controller.pl", { action: 'Inventory/mini_stock',
+                              part_id: function(){ return $('#part_id').val() } },
+           kivi.eval_json_result);
+  };
+
+  ns.reload_stocktaking_history = function(target, source) {
+    var data = $('#stocktaking_form').serializeArray();
+    $.ajax({
+      url:        source,
+      data:       data,
+      success:    function (rsp) {
+        $(target).html(rsp);
+        $(target).find('a.paginate-link').click(function(event){
+          event.preventDefault();
+          kivi.Inventory.reload_stocktaking_history(target, event.target + '')});
+      }
+    });
+  };
+
+  ns.stocktaking_correct_counted = function() {
+    kivi.Inventory.close_already_counted_dialog();
+    kivi.Inventory.save_stocktaking(1);
+  };
+
+  ns.stocktaking_add_counted = function(qty_to_add_to) {
+    resulting_qty = kivi.parse_amount($('#target_qty').val()) + 1.0*qty_to_add_to;
+    $('#target_qty').val(kivi.format_amount(resulting_qty, -2));
+    kivi.Inventory.close_already_counted_dialog();
+    kivi.Inventory.save_stocktaking(1);
+  };
+
+  ns.close_already_counted_dialog = function() {
+    $('#already_counted_dialog').dialog("close");
+  };
+
+});
+
+$(function(){
+  $('#part_id').change(kivi.Inventory.stocktaking_part_changed);
+  $('#warehouse_id').change(kivi.Inventory.reload_bin_selection);
+  $('#cutoff_date_as_date').change(function() {kivi.Inventory.reload_stocktaking_history('#stocktaking_history', 'controller.pl?action=Inventory/reload_stocktaking_history');});
+
+  kivi.Inventory.reload_stocktaking_history('#stocktaking_history', 'controller.pl?action=Inventory/reload_stocktaking_history');
+});
diff --git a/menus/user/00-erp.yaml b/menus/user/00-erp.yaml
index 281aeb11b..3264c3f4a 100644
--- a/menus/user/00-erp.yaml
+++ b/menus/user/00-erp.yaml
@@ -543,6 +543,13 @@
   params:
     action: transfer_warehouse_selection
     trans_type: removal
+- parent: warehouse
+  id: warehouse_stocktaking
+  name: Stocktaking
+  order: 450
+  access: warehouse_management
+  params:
+    action: Inventory/stocktaking
 - parent: warehouse
   id: warehouse_reports
   name: Reports
@@ -571,6 +578,13 @@
   access: warehouse_contents | warehouse_management
   params:
     action: Inventory/stock_usage
+- parent: warehouse_reports
+  id: warehouse_stocktaking_journal
+  name: Stocktaking Journal
+  order: 400
+  access: warehouse_contents | warehouse_management
+  params:
+    action: Inventory/stocktaking_journal
 - id: general_ledger
   name: General Ledger
   icon: gl
diff --git a/templates/webpages/inventory/stocktaking/_already_counted_dialog.html b/templates/webpages/inventory/stocktaking/_already_counted_dialog.html
new file mode 100644
index 000000000..325c79959
--- /dev/null
+++ b/templates/webpages/inventory/stocktaking/_already_counted_dialog.html
@@ -0,0 +1,42 @@
+[%- USE T8 %][%- USE HTML %][%- USE L %][%- USE LxERP %]
+
+<form method="post" id="already_counted_form" method="POST">
+
+  [% 'This part was already counted for this bin:' | $T8 %]<br>
+  [% SELF.part.displayable_name %] / [% SELF.part.ean %]<br>
+  [% already_counted.first.bin.full_description %], [% 'Stocked Qty' | $T8 %]: [%- LxERP.format_amount(stocked_qty, -2) -%]&nbsp;[%- SELF.part.unit -%]
+  [%- IF SELF.part.unit != SELF.unit.name -%]
+    ([%- LxERP.format_amount(stocked_qty_in_form_units, -2) -%]&nbsp;[%- SELF.unit.name -%])<br>
+  [%- END -%]
+  <br>
+  <br>
+  <table>
+    <tr class='listheading'>
+      <th>[% 'Insert Date' | $T8 %]</th>
+      <th>[% 'Employee' | $T8 %]</th>
+      <th>[% 'Bin' | $T8 %]</th>
+      <th>[% 'Target Qty' | $T8 %]</th>
+    </tr>
+    [% FOREACH ac = already_counted %]
+    <tr class='listrow'>
+      <td>[%- ac.itime_as_timestamp -%]</td>
+      <td>[%- ac.employee.safe_name -%]</td>
+      <td>[%- ac.bin.full_description -%]</td>
+      <td class="numeric">[%- ac.qty_as_number -%]&nbsp;[%- ac.part.unit -%]</td>
+    </tr>
+    [% END %]
+  </table>
+
+  <p>
+    [% 'Please choose the action to be processed for your target quantity:' | $T8 %]<br>
+    [% 'Correct counted' | $T8 %]: [% 'The stock will be changed to your target quantity.' | $T8 %]<br>
+    [% 'Add counted' | $T8 %]: [% 'Your target quantity will be added to the stocked quantity.' | $T8 %]<br>
+  </p>
+
+  <br>
+  [% L.hidden_tag('action', 'Inventory/dispatch') %]
+  [% L.button_tag('kivi.Inventory.stocktaking_correct_counted()', LxERP.t8("Correct counted")) %]
+  [% L.button_tag('kivi.Inventory.stocktaking_add_counted(' _ stocked_qty_in_form_units _ ')', LxERP.t8("Add counted")) %]
+  <a href="#" onclick="kivi.Inventory.close_already_counted_dialog();">[%- LxERP.t8("Cancel") %]</a>
+
+</form>
diff --git a/templates/webpages/inventory/stocktaking/_filter.html b/templates/webpages/inventory/stocktaking/_filter.html
new file mode 100644
index 000000000..b09b2bc2d
--- /dev/null
+++ b/templates/webpages/inventory/stocktaking/_filter.html
@@ -0,0 +1,15 @@
+[%- USE T8 %]
+[%- USE HTML %]
+[%- USE L %]
+[%- USE LxERP %]
+
+<table id="filter_table">
+ <tr>
+  <th align="right">[% 'Cutoff Date' | $T8 %]</th>
+  <td>[% L.date_tag('filter.cutoff_date:date', filter.cutoff_date_date) %]</td>
+ </tr>
+ <tr>
+  <th align="right">[% 'Comment' | $T8 %]</th>
+  <td>[% L.input_tag('filter.comment:substr::ilike', filter.comment_substr__ilike, size=60) %]</td>
+ </tr>
+</table>
diff --git a/templates/webpages/inventory/stocktaking/form.html b/templates/webpages/inventory/stocktaking/form.html
new file mode 100644
index 000000000..0f85da304
--- /dev/null
+++ b/templates/webpages/inventory/stocktaking/form.html
@@ -0,0 +1,79 @@
+[%- USE T8 %]
+[%- USE L %]
+[%- USE P %]
+[%- USE HTML %]
+[%- USE LxERP %]
+
+<h1>[% title | html %]</h1>
+
+[%- INCLUDE 'common/flash.html' %]
+
+<form method="post" action="controller.pl" id="stocktaking_form">
+
+ <p>
+  <label for="part_id">[% "Article" | $T8 %]</label>
+  [% P.part.picker("part_id", "") %]
+ </p>
+
+ <p>
+   <div id="stock"></div>
+ </p>
+
+ <table id="stocktaking_settings_table">
+   <tr>
+     <th align="right" nowrap>[% 'Destination warehouse' | $T8 %]</th>
+     <td>[% L.select_tag('warehouse_id', SELF.warehouses, default=SELF.warehouse.id, title_key='description') %]
+       [% IF SELF.warehouse.id %]
+         [% L.select_tag('bin_id', SELF.warehouse.bins, default=SELF.bin.id, title_key='description') %]
+       [%- ELSE %]
+         <span id='bin_id'></span>
+       [% END %]
+     </td>
+   </tr>
+
+   <tr>
+     <th align="right" nowrap>[% 'Charge number' | $T8 %]</th>
+     <td>[% L.input_tag('chargenumber', "", size=30) %]</td>
+   </tr>
+
+   [% IF INSTANCE_CONF.get_show_bestbefore %]
+     <tr>
+       <th align="right" nowrap>[% 'Best Before' | $T8 %]</th>
+       <td>[% L.date_tag('bestbefore', "") %]</td>
+     </tr>
+   [%- END %]
+
+   <tr>
+     <th align="right" nowrap>[% 'Target Qty' | $T8 %]</th>
+     <td>
+       [% L.input_tag('target_qty', '', size=10, class='numeric') %]
+       [%- IF SELF.part.unit %]
+         [% L.select_tag('unit_id', SELF.part.available_units, title_key='name', default=SELF.unit.id) %]
+       [%- ELSE %]
+         [% L.select_tag('unit_id', SELF.units, title_key='name') %]
+       [%- END %]
+     </td>
+   </tr>
+
+   <tr>
+     <th align="right" nowrap>[% 'Cutoff Date' | $T8 %]</th>
+     <td>
+       [% L.date_tag('cutoff_date_as_date', SELF.stocktaking_cutoff_date) %]
+     </td>
+   </tr>
+
+   <tr>
+     <th align="right" nowrap>[% 'Optional comment' | $T8 %]</th>
+     <td>
+       [% L.input_tag('comment', SELF.stocktaking_comment, size=40) %]
+     </td>
+   </tr>
+ </table>
+
+</form>
+
+<p>
+  <div id="stocktaking_history">
+    [%- LxERP.t8("Loading...") %]
+  </div>
+</p>
diff --git a/templates/webpages/inventory/stocktaking/full_report_top.html b/templates/webpages/inventory/stocktaking/full_report_top.html
new file mode 100644
index 000000000..4b13a7e77
--- /dev/null
+++ b/templates/webpages/inventory/stocktaking/full_report_top.html
@@ -0,0 +1,26 @@
+[%- USE L %]
+[%- USE T8 %]
+[%- USE LxERP %]
+[%- USE HTML %]
+<form action='controller.pl' method='post'>
+<div class='filter_toggle'>
+<a href='#' onClick='javascript:$(".filter_toggle").toggle()'>[% 'Show Filter' | $T8 %]</a>
+  [% SELF.filter_summary | html %]
+</div>
+<div class='filter_toggle' style='display:none'>
+<a href='#' onClick='javascript:$(".filter_toggle").toggle()'>[% 'Hide Filter' | $T8 %]</a>
+[%- PROCESS 'inventory/stocktaking/_filter.html' filter=SELF.stocktaking_models.filtered.laundered %]
+
+[% L.hidden_tag('action', 'Inventory/dispatch') %]
+[% L.hidden_tag('sort_by', FORM.sort_by) %]
+[% L.hidden_tag('sort_dir', FORM.sort_dir) %]
+[% L.hidden_tag('page', FORM.page) %]
+[% L.input_tag('action_stocktaking_journal', LxERP.t8('Continue'), type = 'submit', class='submit')%]
+
+
+<a href='#' onClick='javascript:$("#filter_table input").val("");$("#filter_table input[type=checkbox]").prop("checked", 0);'>[% 'Reset' | $T8 %]</a>
+
+</div>
+
+</form>
+ <hr>
diff --git a/templates/webpages/inventory/stocktaking/report_bottom.html b/templates/webpages/inventory/stocktaking/report_bottom.html
new file mode 100644
index 000000000..e147c7102
--- /dev/null
+++ b/templates/webpages/inventory/stocktaking/report_bottom.html
@@ -0,0 +1,2 @@
+[% USE L %]
+[%- L.paginate_controls(models=SELF.stocktaking_models) %]
