commit 9c785acd332352877abd90cd0c79a2fa1ef214a1
Author: Geoffrey Richardson <information@kivitendo-premium.de>
Date:   Wed Dec 7 10:04:33 2016 +0100

    Warengruppen - Umstellung auf Controller, sortkey, obsolete
    
    Neuer CRUD-Controller nur für Warengruppen
    
    Die Reihenfolge der Warengruppen kann nun eingestellt werden, und man kann
    Warengruppen auf ungültig setzen, sofern sie nicht mehr aktiv bei Artikeln in
    Verwendung sind, so daß sie bei neuen Waren nicht mehr ausgewählt werden
    können.

diff --git a/SL/Controller/Part.pm b/SL/Controller/Part.pm
index 27413706c..9458a83f3 100644
--- a/SL/Controller/Part.pm
+++ b/SL/Controller/Part.pm
@@ -872,7 +872,8 @@ sub init_all_languages {
 }
 
 sub init_all_partsgroups {
-  SL::DB::Manager::PartsGroup->get_all_sorted;
+  my ($self) = @_;
+  SL::DB::Manager::PartsGroup->get_all_sorted(query => [ or => [ id => $self->part->partsgroup_id, obsolete => 0 ] ]);
 }
 
 sub init_all_buchungsgruppen {
diff --git a/SL/Controller/PartsGroup.pm b/SL/Controller/PartsGroup.pm
new file mode 100644
index 000000000..603aa1258
--- /dev/null
+++ b/SL/Controller/PartsGroup.pm
@@ -0,0 +1,159 @@
+package SL::Controller::PartsGroup;
+
+use strict;
+
+use parent qw(SL::Controller::Base);
+
+use SL::Helper::Flash;
+use SL::Locale::String;
+use SL::DB::Default;
+use SL::DB::Manager::PartsGroup;
+
+use Rose::Object::MakeMethods::Generic (
+  scalar                  => [ qw(partsgroup) ],
+  'scalar --get_set_init' => [ qw(all_partsgroups) ],
+);
+
+__PACKAGE__->run_before('check_auth');
+__PACKAGE__->run_before('load_partsgroup', only => [ qw(edit update delete) ]);
+
+#
+# actions
+#
+
+sub action_list {
+  my ($self) = @_;
+
+  $self->render('partsgroup/list',
+                title   => t8('Partsgroups'),
+               );
+}
+
+sub action_new {
+  my ($self) = @_;
+
+  $self->partsgroup( SL::DB::PartsGroup->new );
+  $self->render('partsgroup/form',
+                 title => t8('Add partsgroup'),
+               );
+}
+
+sub action_edit {
+  my ($self) = @_;
+
+  $self->render('partsgroup/form',
+                 title   => t8('Edit partsgroup'),
+                );
+}
+
+sub action_create {
+  my ($self) = @_;
+
+  $self->partsgroup( SL::DB::PartsGroup->new );
+  $self->create_or_update;
+}
+
+sub action_update {
+  my ($self) = @_;
+  $self->create_or_update;
+}
+
+sub action_delete {
+  my ($self) = @_;
+
+  if ( !$self->partsgroup->orphaned ) {
+    flash_later('error', $::locale->text('The partsgroup has been used and cannot be deleted.'));
+  } elsif ( eval { $self->partsgroup->delete; 1; } ) {
+    flash_later('info',  $::locale->text('The partsgroup has been deleted.'));
+  } else {
+    flash_later('error', $::locale->text('The partsgroup has been used and cannot be deleted.'));
+  };
+  $self->redirect_to(action => 'list');
+}
+
+sub action_reorder {
+  my ($self) = @_;
+
+  SL::DB::PartsGroup->reorder_list(@{ $::form->{partsgroup_id} || [] });
+  $self->render(\'', { type => 'json' });
+}
+
+#
+# filters
+#
+
+sub check_auth {
+  $::auth->assert('config');
+}
+
+sub load_partsgroup {
+  my ($self) = @_;
+
+  $self->partsgroup( SL::DB::PartsGroup->new(id => $::form->{id})->load );
+}
+
+sub init_all_partsgroups { SL::DB::Manager::PartsGroup->get_all_sorted }
+
+#
+# helpers
+#
+
+sub create_or_update {
+  my ($self) = @_;
+  my $is_new = !$self->partsgroup->id;
+
+  my $params = delete($::form->{partsgroup}) || { };
+
+  $self->partsgroup->assign_attributes(%{ $params });
+
+  my @errors = $self->partsgroup->validate;
+
+  if (@errors) {
+    flash('error', @errors);
+    $self->render('partsgroup/form',
+                   title => $is_new ? t8('Add partsgroup') : t8('Edit partsgroup'),
+                 );
+    return;
+  }
+
+  $self->partsgroup->save;
+
+  flash_later('info', $is_new ? t8('The partsgroup has been created.') : t8('The partsgroup has been saved.'));
+  $self->redirect_to(action => 'list');
+}
+
+1;
+
+__END__
+
+=encoding utf-8
+
+=head1 NAME
+
+SL::Controller::PartsGroup - CRUD controller for partsgroups
+
+=head1 SYNOPSIS
+
+A new controller to create / edit / delete partsgroups.
+
+Partsgroups can only be deleted if they haven't been used anywhere.
+
+=head1 OBSOLETE PARTSGROUPS
+
+A partsgroup can be deleted if it hasn't been used anywhere / is orphaned.
+
+A partsgroup can be set to obsolete, which means new items can't be assigned
+that partsgroup, but old items with that partsgroup can keep it. And you can
+also still filter for these obsolete partsgroups in reports.
+
+=head1 ISSUES
+
+Unlike the old version (pe.pl/PE.pm), there is no way to filter/search the
+partsgroups in the overview page, it always shows the complete (ordered) list,
+ordered by sortkey.
+
+=head1 AUTHOR
+
+G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>
+
+=cut
diff --git a/SL/DB/Manager/PartsGroup.pm b/SL/DB/Manager/PartsGroup.pm
index 24e016c33..9b081331e 100644
--- a/SL/DB/Manager/PartsGroup.pm
+++ b/SL/DB/Manager/PartsGroup.pm
@@ -12,7 +12,7 @@ sub object_class { 'SL::DB::PartsGroup' }
 __PACKAGE__->make_manager_methods;
 
 sub _sort_spec {
-  return ( default => [ 'partsgroup', 1 ],
+  return ( default => [ 'sortkey', 1 ],
            columns => { SIMPLE => 'ALL' });
 }
 
diff --git a/SL/DB/MetaSetup/PartsGroup.pm b/SL/DB/MetaSetup/PartsGroup.pm
index f9915d582..1e3ca3e6b 100644
--- a/SL/DB/MetaSetup/PartsGroup.pm
+++ b/SL/DB/MetaSetup/PartsGroup.pm
@@ -12,7 +12,9 @@ __PACKAGE__->meta->columns(
   id         => { type => 'integer', not_null => 1, sequence => 'id' },
   itime      => { type => 'timestamp', default => 'now()' },
   mtime      => { type => 'timestamp' },
+  obsolete   => { type => 'boolean', default => 'false' },
   partsgroup => { type => 'text' },
+  sortkey    => { type => 'integer', not_null => 1 },
 );
 
 __PACKAGE__->meta->primary_key_columns([ 'id' ]);
diff --git a/SL/DB/PartsGroup.pm b/SL/DB/PartsGroup.pm
index f40c5de96..d6122949c 100644
--- a/SL/DB/PartsGroup.pm
+++ b/SL/DB/PartsGroup.pm
@@ -7,6 +7,7 @@ use strict;
 
 use SL::DB::MetaSetup::PartsGroup;
 use SL::DB::Manager::PartsGroup;
+use SL::DB::Helper::ActsAsList;
 
 __PACKAGE__->meta->add_relationship(
   custom_variable_configs => {
@@ -23,4 +24,34 @@ sub displayable_name {
   return join ' ', grep $_, $self->id, $self->partsgroup;
 }
 
+sub validate {
+  my ($self) = @_;
+  require SL::DB::Customer;
+
+  my @errors;
+
+  push @errors, $::locale->text('The description is missing.') if $self->id and !$self->partsgroup;
+
+  return @errors;
+}
+
+sub orphaned {
+  my ($self) = @_;
+  die 'not an accessor' if @_ > 1;
+
+  return 1 unless $self->id;
+
+  my @relations = qw(
+    SL::DB::Part
+    SL::DB::CustomVariableConfigPartsgroup
+  );
+
+  for my $class (@relations) {
+    eval "require $class";
+    return 0 if $class->_get_manager_class->get_all_count(query => [ partsgroup_id => $self->id ]);
+  }
+
+  return 1;
+}
+
 1;
diff --git a/locale/de/all b/locale/de/all
index 6830b878a..9c31ec1e2 100755
--- a/locale/de/all
+++ b/locale/de/all
@@ -204,6 +204,7 @@ $self->{texts} = {
   'Add new price rule item'     => 'Neue Bedingung hinzufügen',
   'Add note'                    => 'Notiz erfassen',
   'Add part'                    => 'Artikel hinzufügen',
+  'Add partsgroup'              => 'Warengruppe hinzufügen',
   'Add picture'                 => 'Bild hinzufügen',
   'Add picture to text block'   => 'Bild dem Textblock hinzufügen',
   'Add pricegroup'              => 'Preisgruppe hinzufügen',
@@ -1109,6 +1110,7 @@ $self->{texts} = {
   'Edit general settings'       => 'Grundeinstellungen bearbeiten',
   'Edit greetings'              => 'Anreden bearbeiten',
   'Edit note'                   => 'Notiz bearbeiten',
+  'Edit partsgroup'             => 'Warengruppe bearbeiten',
   'Edit payment term'           => 'Zahlungsbedingungen bearbeiten',
   'Edit picture'                => 'Bild bearbeiten',
   'Edit predefined text'        => 'Vordefinierten Textblock bearbeiten',
@@ -2041,6 +2043,7 @@ $self->{texts} = {
   'Parts, services and assemblies' => 'Waren, Dienstleistungen und Erzeugnisse',
   'Partsgroup (database ID)'    => 'Warengruppe (Datenbank-ID)',
   'Partsgroup (name)'           => 'Warengruppe (Name)',
+  'Partsgroups'                 => 'Warengruppen',
   'Partsgroups where variables are shown' => 'Warengruppen, bei denen Variablen angezeigt werden',
   'Password'                    => 'Passwort',
   'Paste'                       => 'Einfügen',
@@ -2982,6 +2985,10 @@ $self->{texts} = {
   'The parts have been removed.' => 'Die Waren wurden aus dem Lager entnommen.',
   'The parts have been stocked.' => 'Die Artikel wurden eingelagert.',
   'The parts have been transferred.' => 'Die Waren wurden umgelagert.',
+  'The partsgroup has been created.' => 'Die Warengruppe wurde erstellt.',
+  'The partsgroup has been deleted.' => 'Die Warengruppe wurde gelöscht.',
+  'The partsgroup has been saved.' => 'Die Warengruppe wurde gespeichert.',
+  'The partsgroup has been used and cannot be deleted.' => 'Die Warengruppe wurde bereits verwendet und kann nicht gelöscht werden.',
   'The password is too long (maximum length: #1).' => 'Das Passwort ist zu lang (maximale Länge: #1).',
   'The password is too short (minimum length: #1).' => 'Das Password ist zu kurz (minimale Länge: #1).',
   'The password is weak (e.g. it can be found in a dictionary).' => 'Das Passwort ist schwach (z.B. wenn es in einem Wörterbuch steht).',
diff --git a/locale/en/all b/locale/en/all
index b3a7c31cb..9ba415aef 100644
--- a/locale/en/all
+++ b/locale/en/all
@@ -158,7 +158,6 @@ $self->{texts} = {
   'Add Follow-Up'               => '',
   'Add Follow-Up for #1'        => '',
   'Add General Ledger Transaction' => '',
-  'Add Group'                   => '',
   'Add Language'                => '',
   'Add Lead'                    => '',
   'Add Letter'                  => '',
@@ -203,6 +202,7 @@ $self->{texts} = {
   'Add new price rule item'     => '',
   'Add note'                    => '',
   'Add part'                    => '',
+  'Add partsgroup'              => '',
   'Add picture'                 => '',
   'Add picture to text block'   => '',
   'Add pricegroup'              => '',
@@ -1106,6 +1106,7 @@ $self->{texts} = {
   'Edit general settings'       => '',
   'Edit greetings'              => '',
   'Edit note'                   => '',
+  'Edit partsgroup'             => '',
   'Edit payment term'           => '',
   'Edit picture'                => '',
   'Edit predefined text'        => '',
@@ -2032,8 +2033,10 @@ $self->{texts} = {
   'Parts Master Data'           => '',
   'Parts with existing part numbers' => '',
   'Parts, services and assemblies' => '',
+  'Partsgroup'                  => '',
   'Partsgroup (database ID)'    => '',
   'Partsgroup (name)'           => '',
+  'Partsgroups'                 => '',
   'Partsgroups where variables are shown' => '',
   'Password'                    => '',
   'Paste'                       => '',
@@ -2976,6 +2979,10 @@ $self->{texts} = {
   'The parts have been removed.' => '',
   'The parts have been stocked.' => '',
   'The parts have been transferred.' => '',
+  'The partsgroup has been created.' => '',
+  'The partsgroup has been deleted.' => '',
+  'The partsgroup has been saved.' => '',
+  'The partsgroup has been used and cannot be deleted.' => '',
   'The password is too long (maximum length: #1).' => '',
   'The password is too short (minimum length: #1).' => '',
   'The password is weak (e.g. it can be found in a dictionary).' => '',
diff --git a/menus/user/00-erp.yaml b/menus/user/00-erp.yaml
index 8d8741fca..9c4f97789 100644
--- a/menus/user/00-erp.yaml
+++ b/menus/user/00-erp.yaml
@@ -1081,13 +1081,11 @@
   params:
     action: BankAccount/list
 - parent: system
-  id: system_groups
-  name: Groups
+  id: system_partsgroups
+  name: Partsgroups
   order: 900
-  module: pe.pl
   params:
-    action: search
-    type: partsgroup
+    action: PartsGroup/list
 - parent: system
   id: system_pricegroups
   name: Pricegroups
diff --git a/sql/Pg-upgrade2/partsgroup_sortkey_obsolete.sql b/sql/Pg-upgrade2/partsgroup_sortkey_obsolete.sql
new file mode 100644
index 000000000..6b9ec79fd
--- /dev/null
+++ b/sql/Pg-upgrade2/partsgroup_sortkey_obsolete.sql
@@ -0,0 +1,13 @@
+-- @tag: partsgroup_sortkey_obsolete
+-- @description: Sortierreihenfolge und ungültig für Warengruppen
+-- @charset: UTF-8
+-- @depends: release_3_4_1
+-- @ignore: 0
+
+ALTER TABLE partsgroup ADD COLUMN obsolete BOOLEAN DEFAULT FALSE;
+ALTER TABLE partsgroup ADD COLUMN sortkey INTEGER;
+
+CREATE SEQUENCE tmp_counter;
+UPDATE partsgroup SET sortkey = nextval('tmp_counter');
+DROP SEQUENCE tmp_counter;
+ALTER TABLE partsgroup ALTER COLUMN sortkey SET NOT NULL;
diff --git a/templates/webpages/partsgroup/form.html b/templates/webpages/partsgroup/form.html
new file mode 100644
index 000000000..60134b736
--- /dev/null
+++ b/templates/webpages/partsgroup/form.html
@@ -0,0 +1,50 @@
+[%- USE HTML -%][%- USE LxERP -%][%- USE L -%][%- USE T8 -%]
+
+[% SET style="width: 400px" %]
+[% SET size=15 %]
+
+<h1>[% HTML.escape(title) %]</h1>
+
+<form action="controller.pl" method="post">
+
+[%- INCLUDE 'common/flash.html' %]
+
+[%- L.hidden_tag("id", SELF.partsgroup.id) %]
+
+<table>
+  <tr>
+    <th align="right">[% 'Description' | $T8 %]</th>
+    <td>
+       [%- L.input_tag("partsgroup.partsgroup", SELF.partsgroup.partsgroup) %]
+   </td>
+  [% IF SELF.partsgroup.id %]
+  <tr>
+    <th align="right">[% 'Obsolete' | $T8 %]</th>
+    <td>[% L.checkbox_tag('partsgroup.obsolete', checked = SELF.partsgroup.obsolete, for_submit=1) %]</td>
+  </tr>
+  </tr>
+  [% END %]
+</table>
+
+ <p>
+  [% L.hidden_tag("action", "PartsGroup/dispatch") %]
+  [% L.submit_tag("action_" _  (SELF.partsgroup.id ? "update" : "create"), LxERP.t8('Save'), onclick="return check_prerequisites();") %]
+  [%- IF SELF.partsgroup.id AND SELF.partsgroup.orphaned -%]
+    [% L.submit_tag("action_delete", LxERP.t8('Delete')) %]
+  [%- END %]
+  <a href="[% SELF.url_for(action='list') %]">[%- LxERP.t8("Cancel") %]</a>
+ </p>
+
+ <hr>
+
+<script type="text/javascript">
+<!--
+function check_prerequisites() {
+  if ($('#partsgroup_partsgroup').val() === "") {
+    alert(kivi.t8('The description is missing.'));
+    return false;
+  }
+  return true;
+}
+-->
+</script>
